From 13f033f091fec6f8503bdc5b1f8a8a7ab9aff268 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:28:49 -0700 Subject: [PATCH 001/101] chore(daemon): disambiguate audit observations (#12343) * chore(daemon): disambiguate audit observations * fix(daemon): reject future audit protocol roles * fix(telemetry): protect daemon audit observations --- .../daemon-audit-eligibility-event.test.ts | 74 ++++++++++++++++++- .../daemon/daemon-audit-eligibility-event.ts | 73 +++++++++++++++--- src/main/ipc/telemetry.test.ts | 1 + src/main/ipc/telemetry.ts | 1 + src/shared/daemon-audit-eligibility.ts | 2 + src/shared/telemetry-events.ts | 55 ++++++++------ 6 files changed, 170 insertions(+), 36 deletions(-) diff --git a/src/main/daemon/daemon-audit-eligibility-event.test.ts b/src/main/daemon/daemon-audit-eligibility-event.test.ts index 52171e14853..13d3e0f39ed 100644 --- a/src/main/daemon/daemon-audit-eligibility-event.test.ts +++ b/src/main/daemon/daemon-audit-eligibility-event.test.ts @@ -4,7 +4,9 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { validate } from '../telemetry/validator' import { recordAuthenticatedInventory, type DaemonAuditContext } from './daemon-audit-classifier' +import type { ExactDaemonIncarnation } from './daemon-incarnation-evidence' import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { PROTOCOL_VERSION } from './daemon-protocol-version' import { DaemonServer } from './daemon-server' import { getDaemonSocketPath } from './daemon-spawner' @@ -25,6 +27,16 @@ const context: DaemonAuditContext = { profileScope: '/profile' } +const exactIncarnation: ExactDaemonIncarnation = { + identity: { + pid: 92_847_561, + startedAtMs: 1_786_000_123_456, + launchNonce: 'private-launch-nonce' + }, + linuxStartTicks: '8642097531', + bootId: 'private-boot-id' +} + beforeEach(() => { trackMock.mockReset() }) @@ -42,12 +54,70 @@ describe('daemon audit eligibility telemetry', () => { reason: 'authenticated_inventory', evidence_sources: ['authenticated_inventory'], protocol_generation: 23, + generation_role: 'legacy', exact_incarnation: 'unavailable', process_reason: null }) + expect(props).not.toHaveProperty('exact_incarnation_correlation') expect(validate('daemon_audit_eligibility', props).ok).toBe(true) }) + it('correlates an exact incarnation across app trackers without raw identity fields', () => { + const currentContext = { ...context, protocolGeneration: PROTOCOL_VERSION } + const firstAppTracker = createDaemonAuditEligibilityTracker() + const secondAppTracker = createDaemonAuditEligibilityTracker() + + firstAppTracker(recordAuthenticatedInventory(currentContext, exactIncarnation)) + secondAppTracker(recordAuthenticatedInventory(currentContext, exactIncarnation)) + secondAppTracker( + recordAuthenticatedInventory(currentContext, { + ...exactIncarnation, + identity: { ...exactIncarnation.identity, launchNonce: 'another-private-launch-nonce' } + }) + ) + + const firstProperties = trackMock.mock.calls[0][1] + const secondProperties = trackMock.mock.calls[1][1] + const replacementProperties = trackMock.mock.calls[2][1] + expect(firstProperties).toMatchObject({ + generation_role: 'current', + exact_incarnation: 'endpoint-identity-linux-ticks', + exact_incarnation_correlation: 'v1:2751326d0f457808fe11a03ce2f6e732' + }) + expect(secondProperties.exact_incarnation_correlation).toBe( + firstProperties.exact_incarnation_correlation + ) + expect(replacementProperties.exact_incarnation_correlation).not.toBe( + firstProperties.exact_incarnation_correlation + ) + expect(validate('daemon_audit_eligibility', firstProperties).ok).toBe(true) + + const serializedProperties = JSON.stringify(firstProperties) + for (const rawIdentity of [ + exactIncarnation.identity.launchNonce, + String(exactIncarnation.identity.pid), + String(exactIncarnation.identity.startedAtMs), + exactIncarnation.linuxStartTicks, + exactIncarnation.bootId + ]) { + expect(serializedProperties).not.toContain(rawIdentity) + } + }) + + it('does not mislabel a future protocol generation as legacy', () => { + const trackEligibility = createDaemonAuditEligibilityTracker() + + expect(() => + trackEligibility( + recordAuthenticatedInventory( + { ...context, protocolGeneration: PROTOCOL_VERSION + 1 }, + exactIncarnation + ) + ) + ).not.toThrow() + expect(trackMock).not.toHaveBeenCalled() + }) + it('cannot affect callers when telemetry throws', () => { trackMock.mockImplementation(() => { throw new Error('transport failed') @@ -83,13 +153,13 @@ describe('daemon audit eligibility telemetry', () => { for (let index = 0; index < 60; index += 1) { nowMs += 1_000 - trackEligibility(recordAuthenticatedInventory(context, null)) + trackEligibility(recordAuthenticatedInventory(context, exactIncarnation)) } expect(trackMock).toHaveBeenCalledOnce() nowMs += 5 * 60_000 - trackEligibility(recordAuthenticatedInventory(context, null)) + trackEligibility(recordAuthenticatedInventory(context, exactIncarnation)) expect(trackMock).toHaveBeenCalledTimes(2) }) diff --git a/src/main/daemon/daemon-audit-eligibility-event.ts b/src/main/daemon/daemon-audit-eligibility-event.ts index 3afaaa8aad5..e99ac903eb8 100644 --- a/src/main/daemon/daemon-audit-eligibility-event.ts +++ b/src/main/daemon/daemon-audit-eligibility-event.ts @@ -1,15 +1,22 @@ +import { createHash } from 'node:crypto' import { track } from '../telemetry/client' import type { EventProps } from '../../shared/telemetry-events' import type { DaemonAuditObservation } from './daemon-audit-classifier' +import { PROTOCOL_VERSION } from './daemon-protocol-version' // Why: a steady daemon repeats a byte-identical observation on every listProcesses call, so // repeats are re-sent only as an occasional heartbeat — the shared per-session telemetry // ceiling is 1,000 events for the whole app and audit data must not crowd it out. const REPEATED_OBSERVATION_INTERVAL_MS = 5 * 60_000 +const INCARNATION_CORRELATION_DOMAIN = 'orca:daemon-audit-eligibility:incarnation:v1' +type AuditEligibilityCommonProperties = Omit< + Extract, { exact_incarnation: 'unavailable' }>, + 'exact_incarnation' +> export function trackDaemonAuditEligibility(observation: DaemonAuditObservation): void { try { - track('daemon_audit_eligibility', auditEligibilityProperties(observation)) + track('daemon_audit_eligibility', auditEligibilityProperties(observation, hashLaunchNonce)) } catch { // Audit telemetry cannot affect daemon availability. } @@ -22,11 +29,13 @@ export function createDaemonAuditEligibilityTracker( ): (observation: DaemonAuditObservation) => void { let lastProperties: string | null = null let lastTrackedAtMs = 0 + const correlateLaunchNonce = createLaunchNonceCorrelationCache() return (observation) => { // Why: the rate-limit bookkeeping runs inside the daemon's inventory path, so it is guarded // together with the emit — audit telemetry cannot affect daemon availability. try { - const properties = JSON.stringify(auditEligibilityProperties(observation)) + const eventProperties = auditEligibilityProperties(observation, correlateLaunchNonce) + const properties = JSON.stringify(eventProperties) const observedAtMs = monotonicNowMs() const elapsedMs = observedAtMs - lastTrackedAtMs if ( @@ -38,7 +47,7 @@ export function createDaemonAuditEligibilityTracker( } lastProperties = properties lastTrackedAtMs = observedAtMs - trackDaemonAuditEligibility(observation) + track('daemon_audit_eligibility', eventProperties) } catch { // Audit telemetry cannot affect daemon availability. } @@ -46,33 +55,73 @@ export function createDaemonAuditEligibilityTracker( } function auditEligibilityProperties( - observation: DaemonAuditObservation + observation: DaemonAuditObservation, + correlateLaunchNonce: (launchNonce: string) => string ): EventProps<'daemon_audit_eligibility'> { - return { + const generationRole = generationRoleForProtocol(observation.context.protocolGeneration) + const commonProperties: AuditEligibilityCommonProperties = { state: observation.state, reason: observation.reason, trigger: observation.trigger, evidence_sources: [...observation.evidenceSources], protocol_generation: observation.context.protocolGeneration, + generation_role: generationRole, provider: observation.context.provider, endpoint_kind: observation.context.endpointKind, profile_scope: observation.context.profileScope ? 'configured' : 'unspecified', - exact_incarnation: exactIncarnationKind(observation), reachability: observation.reachability, inventory_authority: observation.inventoryAuthority, process_liveness: observation.processLiveness, process_reason: observation.processReason, endpoint_state: observation.endpointState } + if (!observation.exactIncarnation) { + return { ...commonProperties, exact_incarnation: 'unavailable' } + } + return { + ...commonProperties, + exact_incarnation: exactIncarnationKind(observation.exactIncarnation), + exact_incarnation_correlation: correlateLaunchNonce( + observation.exactIncarnation.identity.launchNonce + ) + } +} + +function generationRoleForProtocol(protocolGeneration: number): 'current' | 'legacy' { + if (protocolGeneration === PROTOCOL_VERSION) { + return 'current' + } + if (protocolGeneration > 0 && protocolGeneration < PROTOCOL_VERSION) { + return 'legacy' + } + throw new Error('unsupported_daemon_audit_protocol_generation') } function exactIncarnationKind( - observation: DaemonAuditObservation -): 'endpoint-identity' | 'endpoint-identity-linux-ticks' | 'unavailable' { - if (!observation.exactIncarnation) { - return 'unavailable' - } - return observation.exactIncarnation.linuxStartTicks && observation.exactIncarnation.bootId + exactIncarnation: NonNullable +): 'endpoint-identity' | 'endpoint-identity-linux-ticks' { + return exactIncarnation.linuxStartTicks && exactIncarnation.bootId ? 'endpoint-identity-linux-ticks' : 'endpoint-identity' } + +function createLaunchNonceCorrelationCache(): (launchNonce: string) => string { + let cachedNonce: string | null = null + let cachedCorrelation = '' + return (launchNonce) => { + if (launchNonce !== cachedNonce) { + cachedNonce = launchNonce + cachedCorrelation = hashLaunchNonce(launchNonce) + } + return cachedCorrelation + } +} + +function hashLaunchNonce(launchNonce: string): string { + const digest = createHash('sha256') + .update(INCARNATION_CORRELATION_DOMAIN) + .update('\0') + .update(launchNonce) + .digest('hex') + return `v1:${digest.slice(0, 32)}` +} diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 1f412a6d0e9..2645d98dff5 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -160,6 +160,7 @@ describe('telemetry IPC handlers', () => { count_bucket: 'count_1', bucket_source: 'crossed_now' }) + handler({}, 'daemon_audit_eligibility', {}) expect(trackMock).not.toHaveBeenCalled() expect(getCohortAtEmitMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index 02f1b9e16be..c1fe7d0f68d 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -24,6 +24,7 @@ let storeRef: Store | null = null const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'app_starred_orca', + 'daemon_audit_eligibility', 'star_nag_outcome', 'feature_interaction_usage_bucket_reached' ]) diff --git a/src/shared/daemon-audit-eligibility.ts b/src/shared/daemon-audit-eligibility.ts index 4e89af300eb..4a5d28ca740 100644 --- a/src/shared/daemon-audit-eligibility.ts +++ b/src/shared/daemon-audit-eligibility.ts @@ -1,5 +1,7 @@ export const DAEMON_AUDIT_STATE_VALUES = ['present', 'gone', 'unknown'] as const +export const DAEMON_AUDIT_GENERATION_ROLE_VALUES = ['current', 'legacy'] as const + export const DAEMON_AUDIT_TRIGGER_VALUES = [ 'inventory_answered', 'inventory_failed', diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 18f7cd8986f..490bb162af6 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -28,6 +28,7 @@ import { DAEMON_RETIRE_REASONS } from './daemon-lifecycle-telemetry' import { + DAEMON_AUDIT_GENERATION_ROLE_VALUES, DAEMON_AUDIT_PROCESS_REASON_VALUES, DAEMON_AUDIT_REASON_VALUES, DAEMON_AUDIT_STATE_VALUES, @@ -419,28 +420,38 @@ const daemonLifecycleSchema = z.discriminatedUnion('transition', [ .strict() ]) -const daemonAuditEligibilitySchema = z - .object({ - state: z.enum(DAEMON_AUDIT_STATE_VALUES), - reason: z.enum(DAEMON_AUDIT_REASON_VALUES), - trigger: z.enum(DAEMON_AUDIT_TRIGGER_VALUES), - evidence_sources: z.array(z.enum(DAEMON_EVIDENCE_SOURCE_VALUES)).min(1).max(12), - protocol_generation: z.number().int().positive().max(1_000), - provider: z.literal('local-daemon'), - endpoint_kind: z.enum(['unix-socket', 'windows-named-pipe']), - profile_scope: z.enum(['configured', 'unspecified']), - exact_incarnation: z.enum([ - 'endpoint-identity', - 'endpoint-identity-linux-ticks', - 'unavailable' - ]), - reachability: z.enum(['authenticated', 'disconnected', 'unknown']), - inventory_authority: z.enum(['authoritative', 'unavailable']), - process_liveness: z.enum(['present', 'gone', 'unknown']), - process_reason: z.enum(DAEMON_AUDIT_PROCESS_REASON_VALUES).nullable(), - endpoint_state: z.enum(['missing', 'named-pipe', 'non-socket', 'socket', 'unknown']) - }) - .strict() +const daemonAuditEligibilityBaseSchema = z.object({ + state: z.enum(DAEMON_AUDIT_STATE_VALUES), + reason: z.enum(DAEMON_AUDIT_REASON_VALUES), + trigger: z.enum(DAEMON_AUDIT_TRIGGER_VALUES), + evidence_sources: z.array(z.enum(DAEMON_EVIDENCE_SOURCE_VALUES)).min(1).max(12), + protocol_generation: z.number().int().positive().max(1_000), + generation_role: z.enum(DAEMON_AUDIT_GENERATION_ROLE_VALUES), + provider: z.literal('local-daemon'), + endpoint_kind: z.enum(['unix-socket', 'windows-named-pipe']), + profile_scope: z.enum(['configured', 'unspecified']), + reachability: z.enum(['authenticated', 'disconnected', 'unknown']), + inventory_authority: z.enum(['authoritative', 'unavailable']), + process_liveness: z.enum(['present', 'gone', 'unknown']), + process_reason: z.enum(DAEMON_AUDIT_PROCESS_REASON_VALUES).nullable(), + endpoint_state: z.enum(['missing', 'named-pipe', 'non-socket', 'socket', 'unknown']) +}) + +const daemonAuditEligibilitySchema = z.discriminatedUnion('exact_incarnation', [ + daemonAuditEligibilityBaseSchema + .extend({ + exact_incarnation: z.literal('endpoint-identity'), + exact_incarnation_correlation: z.string().regex(/^v1:[0-9a-f]{32}$/) + }) + .strict(), + daemonAuditEligibilityBaseSchema + .extend({ + exact_incarnation: z.literal('endpoint-identity-linux-ticks'), + exact_incarnation_correlation: z.string().regex(/^v1:[0-9a-f]{32}$/) + }) + .strict(), + daemonAuditEligibilityBaseSchema.extend({ exact_incarnation: z.literal('unavailable') }).strict() +]) // Rollout signal for granting Codex hook trust via codex app-server RPCs // instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed` From 98e8d1c41d25cba00043f457791a32e3dfc74ac3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:05:04 -0700 Subject: [PATCH 002/101] fix(terminal): reconcile status after escape interrupt (#12345) * fix(terminal): reconcile status after escape interrupt * fix(terminal): preserve absent interrupt baseline * fix(terminal): ignore stale input acknowledgments * fix(terminal): order interrupt acknowledgments --- .../agent-interrupt-inference.test.ts | 74 ++++++++++++++++++- .../agent-interrupt-inference.ts | 30 +++++++- .../terminal-pane/pty-connection.test.ts | 14 ++-- .../terminal-pane/pty-connection.ts | 15 +++- 4 files changed, 118 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts index 83252ccf93c..98cc941aadd 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts @@ -204,10 +204,10 @@ describe('agent interrupt inference', () => { now: () => 1_100 }) - tracker.observeInputIntent('plain-escape') + tracker.observeInputIntent('plain-escape', entry, 1) expect(inferInterrupt).not.toHaveBeenCalled() - tracker.observeInputIntent('plain-escape') + tracker.observeInputIntent('plain-escape', entry, 1) expect(inferInterrupt).toHaveBeenCalledWith({ paneKey: PANE_KEY, @@ -422,6 +422,76 @@ describe('agent interrupt inference', () => { expect(inferInterrupt).not.toHaveBeenCalled() }) + it('does not rearm after disposal', () => { + vi.useFakeTimers() + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => makeEntry(), + inferInterrupt, + now: () => 1_100 + }) + + tracker.dispose() + tracker.observeInputIntent('plain-escape') + vi.advanceTimersByTime(500) + + expect(inferInterrupt).not.toHaveBeenCalled() + }) + + it('does not infer against a status created after the input baseline', () => { + vi.useFakeTimers() + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => makeEntry(), + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape', null) + vi.advanceTimersByTime(500) + + expect(inferInterrupt).not.toHaveBeenCalled() + }) + + it.each([ + ['an older status', makeEntry()], + ['an absent status', null] + ] as const)( + 'does not let a delayed acknowledgment for %s cancel a newer turn after cleanup', + (_label, olderEntry) => { + vi.useFakeTimers() + const newerEntry = makeEntry({ + prompt: 'newer task', + updatedAt: 2_000, + stateStartedAt: 1_900 + }) + let currentEntry: AgentStatusEntry | undefined = newerEntry + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => currentEntry, + inferInterrupt, + now: () => 2_100 + }) + + tracker.observeInputIntent('plain-escape', newerEntry, 2) + currentEntry = undefined + tracker.observeInputIntent('plain-escape', olderEntry, 1) + vi.advanceTimersByTime(500) + + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 2_000, + baselineStateStartedAt: 1_900, + baselinePrompt: 'newer task', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + } + ) + it('requires exact plain Escape and Ctrl+C key events', () => { expect(isPlainEscapeKeyEvent(keyEvent({ key: 'Escape' }))).toBe(true) expect(isCtrlCKeyEvent(keyEvent({ key: 'c', ctrlKey: true }))).toBe(true) diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts index d4ff64f6662..b847badcdfc 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts @@ -11,7 +11,11 @@ import { isAskUserQuestionTool } from '../../../../shared/agent-question-answere import { isExplicitAgentStatusFresh } from '@/lib/agent-status' export type AgentInterruptInference = { - observeInputIntent(intent: AgentInterruptInputIntent): void + observeInputIntent( + intent: AgentInterruptInputIntent, + entry?: AgentStatusEntry | null, + baselineSequence?: number + ): void flushPending(): boolean | Promise dispose(): void } @@ -112,10 +116,12 @@ export function createAgentInterruptInference({ setTimer = (callback, ms) => setTimeout(callback, ms), clearTimer = (timer) => clearTimeout(timer) }: AgentInterruptInferenceDeps): AgentInterruptInference { + let disposed = false let pendingTimer: ReturnType | null = null let pendingBaseline: CapturedInterruptBaseline | null = null let doubleEscapeBaseline: CapturedInterruptBaseline | null = null let doubleEscapeTimer: ReturnType | null = null + let latestBaselineSequence = 0 const clearPendingTimer = (): void => { if (pendingTimer !== null) { @@ -159,6 +165,9 @@ export function createAgentInterruptInference({ } const flushPending = (): boolean | Promise => { + if (disposed) { + return false + } const baseline = pendingBaseline pendingTimer = null pendingBaseline = null @@ -199,8 +208,22 @@ export function createAgentInterruptInference({ } return { - observeInputIntent(intent) { - const entry = getStatusEntry() + observeInputIntent(intent, capturedEntry, baselineSequence) { + if (disposed) { + return + } + if (baselineSequence !== undefined) { + if (baselineSequence < latestBaselineSequence) { + return + } + latestBaselineSequence = baselineSequence + } + const currentEntry = getStatusEntry() + // Why: an older acknowledged write must not replace a newer turn's pending inference. + if (capturedEntry !== undefined && currentEntry && currentEntry !== capturedEntry) { + return + } + const entry = capturedEntry === undefined ? currentEntry : capturedEntry if (!entry) { clearPending() return @@ -247,6 +270,7 @@ export function createAgentInterruptInference({ }, flushPending, dispose() { + disposed = true clearPending() } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index b1179487b09..701dd485890 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -7631,7 +7631,7 @@ describe('connectPanePty', () => { expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') }) - it('flushes pending interrupt inference before dropping an exited foreground agent command', async () => { + it('pins interrupt inference before acknowledged input and command exit cleanup', async () => { const { connectPanePty } = await import('./pty-connection') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -7700,18 +7700,14 @@ describe('connectPanePty', () => { if (!onDataHandler) { throw new Error('expected onData handler to be registered') } - terminalTarget.dispatch( - keyEvent({ - key: 'c', - ctrlKey: true - }) - ) - ;(onDataHandler as unknown as (data: string) => void)('\x03') + terminalTarget.dispatch(keyEvent({ key: 'Escape' })) + ;(onDataHandler as unknown as (data: string) => void)('\x1b') capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + delete mockStoreState.agentStatusByPaneKey[paneKey] writeAccepted.resolve(true) await flushAsyncTicks() @@ -7721,7 +7717,7 @@ describe('connectPanePty', () => { baselineStateStartedAt: 900, baselinePrompt: 'stop quickly', baselineAgentType: 'codex', - intent: 'ctrl-c' + intent: 'plain-escape' }) expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 4bae686c49e..e4b03fcb5ff 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1952,6 +1952,8 @@ export function connectPanePty( questionAnsweredInference.observeSentTerminalInput(data) } let pendingTerminalInputWrite: Promise | null = null + let sequencedInterruptStatusBaseline: AgentStatusEntry | null | undefined + let interruptStatusBaselineSequence = 0 const setPendingTerminalInputWrite = (promise: Promise): void => { pendingTerminalInputWrite = promise void promise.finally(() => { @@ -4055,6 +4057,13 @@ export function connectPanePty( // excluded because those transports do not expose sendInputAccepted. const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) if (acknowledgedIntent && transport.sendInputAccepted) { + const interruptStatusBaseline = useAppStore.getState().agentStatusByPaneKey[cacheKey] ?? null + // Why: equal snapshots retain double-Escape semantics while older snapshots lose ack races. + if (sequencedInterruptStatusBaseline !== interruptStatusBaseline) { + sequencedInterruptStatusBaseline = interruptStatusBaseline + interruptStatusBaselineSequence += 1 + } + const capturedBaselineSequence = interruptStatusBaselineSequence claimViewportForUserActivity() if (acknowledgedIntent === 'ctrl-c') { // Why: the accepted-write callback is async; let the next command be @@ -4070,7 +4079,11 @@ export function connectPanePty( markAcceptedTerminalInputSent() observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, acknowledgedIntent) - interruptInference.observeInputIntent(acknowledgedIntent) + interruptInference.observeInputIntent( + acknowledgedIntent, + interruptStatusBaseline, + capturedBaselineSequence + ) observeTitleOnlyInterrupt() } else { // Why: Esc/Ctrl+C are the first keys users press on a frozen pane; From f4b2b782b5357d22472920c24929036f9d9b2aa9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:17:26 -0700 Subject: [PATCH 003/101] feat(orchestration): coordinator-driven release of settled worker terminals (STA-905) (#12355) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/reliability-gates.jsonc | 100 ++- .../orchestration-skill-guidance.test.mjs | 69 +- skill-guides/orchestration.md | 15 +- src/cli/bundled-skill-guides.ts | 2 +- src/cli/handler-group-manifest.ts | 3 + src/cli/handlers/orchestration.ts | 110 +++ src/cli/help.ts | 6 + src/cli/index.test.ts | 9 + src/cli/specs/orchestration-worker-specs.ts | 34 + src/main/runtime/orca-runtime.ts | 6 + src/main/runtime/orchestration/db.ts | 672 ++++++++++++++- ...hestration-db-retention-pagination.test.ts | 4 +- .../orchestration/worker-output-archive.ts | 132 +++ .../worker-terminal-ownership.ts | 134 +++ .../worker-terminal-release-reconciliation.ts | 101 +++ .../worker-transcript-read.test.ts | 43 + .../orchestration/worker-transcript-read.ts | 24 +- .../orchestration-worker-archive-read.ts | 226 ++++++ .../methods/orchestration-worker-control.ts | 90 +-- ...hestration-worker-legacy-federated-read.ts | 76 ++ .../methods/orchestration-worker-methods.ts | 4 +- .../methods/orchestration-worker-output.ts | 2 +- ...orchestration-worker-release-completion.ts | 292 +++++++ ...hestration-worker-release-recovery.test.ts | 314 +++++++ .../orchestration-worker-release.test.ts | 763 ++++++++++++++++++ .../methods/orchestration-worker-release.ts | 161 ++++ .../methods/orchestration-worker-topology.ts | 3 +- .../rpc/methods/orchestration-workers.ts | 3 +- .../runtime/rpc/methods/orchestration.test.ts | 6 +- .../rpc/orchestration-mutation-executor.ts | 36 +- .../rpc/orchestration-mutation-ledger.test.ts | 48 ++ .../terminal-pane/pty-connection.ts | 8 +- .../worker-terminal-takeover-report.test.ts | 43 + .../lib/worker-terminal-takeover-report.ts | 61 ++ src/shared/orchestration-rpc-contract.ts | 2 + src/shared/orchestration-worker-output.ts | 4 + 36 files changed, 3495 insertions(+), 111 deletions(-) create mode 100644 src/main/runtime/orchestration/worker-output-archive.ts create mode 100644 src/main/runtime/orchestration/worker-terminal-ownership.ts create mode 100644 src/main/runtime/orchestration/worker-terminal-release-reconciliation.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-legacy-federated-read.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-release-completion.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-release.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-release.ts create mode 100644 src/renderer/src/lib/worker-terminal-takeover-report.test.ts create mode 100644 src/renderer/src/lib/worker-terminal-takeover-report.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 585640c7b8d..de0afcf8a90 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-01", + "updatedAt": "2026-08-03", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -6733,6 +6733,104 @@ ], "demotionRule": "Keep experimental or demote if either Electron journey flakes without a product or harness defect, if local worker-start can return before tab materialization without an explicit reveal warning, if focus moves to the worker, if restart or workspace re-entry spawns/resumes/duplicates/interferes with the worker, if retained output becomes unreadable, or if pane-stable delivery reads the wrong mailbox." }, + { + "id": "orchestration.settled-worker-terminal-release", + "title": "Settled worker cleanup preserves one exact terminal lease and immutable output", + "maturity": "experimental", + "protection": "partial", + "owner": "orchestration", + "layer": "runtime-sqlite-terminal-lifecycle", + "surfaces": [ + "worker-release", + "worker-retain", + "explicit terminal reuse", + "restart reconciliation", + "worker-read archives", + "orchestration reset" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, conservative schema-v23 backfill, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. Live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/12355", "STA-905"], + "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.", + "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", + "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts", + "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts", + "src/main/runtime/orchestration/worker-transcript-read.test.ts", + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", + "assertions": [ + "rejects exact reuse after release intent instead of closing the new worker", + "lets an explicit retain cancel a release while output capture is pending", + "retains when the terminal host scope changed instead of closing", + "reads an immutable transcript snapshot after the provider file disappears", + "backfills a legacy creator plus explicit reuser as ambiguous", + "removes terminal authority and archived output on orchestration reset" + ] + }, + { + "file": "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts", + "assertions": ["resumes a pending idempotent worker release after restart"] + }, + { + "file": "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts", + "assertions": [ + "finishes a requested release after restart-style interruption", + "coalesces overlapping reconciliation passes and closes each resource once", + "keeps live terminals bounded across 50 settled workers while controls survive" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 3.48, + "summary": "Five focused files passed 56 tests covering lease serialization, reminted-handle transfer, duplicate-identity fencing, retain and takeover races, immutable archives, conservative legacy migration, mutation restart, reset cleanup, bounded accounting, and renderer input reporting." + } + ], + "runtimeBudget": { + "p95Seconds": 8, + "scope": "deterministic runtime, SQLite, transcript, renderer-input, and mutation contracts" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "First local deterministic run only; focused CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical 56-test oracle produced 12 deterministic failures and 44 passes with production restored to exact PR head 4aa66b62e3, including ready reuse during release, unsafe migration, stale archives, mutable transcript output, host/identity close gaps, and mutation replay loss. Candidate 457bc4edd1 passed all 56; restoring the same five production files to 4aa66b62e3 reproduced the same red state." + }, + "performanceBudget": { + "required": true, + "evidence": "Release performs constant-count indexed resource and identity queries plus one bounded archive capture. Worker-list uses two set queries rather than one resource lookup per worker. Reconciliation remains serial and adds no polling, timers, subprocesses, renderer subscriptions, or provider-wide listing." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Add live headed and headless paired-runtime release/reuse journeys.", + "Add Docker SSH reconnect and physical Windows WSL host-scope evidence.", + "Prove provider-close lost acknowledgements converge with an exact durable close receipt." + ], + "knownGaps": [ + "Provider close has no durable cross-process operation receipt, so a crash after host mutation but before SQLite settlement remains release_pending until exact inventory returns.", + "Federated release remains explicitly unsupported and retained.", + "Mobile/direct remote input takeover and live Linux, Windows, WSL, SSH, headed, and headless paired-runtime release are not exercised." + ], + "demotionRule": "Keep experimental or demote if release can overlap exact reuse, close a conflicting lease, lose immutable output, retain reset archives, fan out per worker, or any focused ordering test flakes without a product or harness defect." + }, { "id": "terminal-render.windows-cjk-repaint", "title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells", diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 849fe784b46..e869f652e40 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -227,12 +227,79 @@ describe('orchestration skill guidance', () => { expect(agentGuidance).toContain('After sending `worker_done`, end your turn') expect(agentGuidance).toContain('idle at the agent prompt') - expect(agentGuidance).toContain('Do not poll or keep calling `orca orchestration check`') + expect(agentGuidance).toContain( + 'do not start more work, poll, or attempt to close the terminal yourself' + ) expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input') expect(skill).not.toContain('post-completion polling messages') expect(skill).not.toContain('every 2 minutes') }) + it('makes settled worker terminal release an explicit coordinator step', () => { + const skill = readSkill() + const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop') + const agentGuidance = getSection(skill, 'Agent Guidance') + const nextAction = getSection(skill, 'Next Action') + + expect(workerLoop).toContain( + '# Process every message. For each accepted worker_done that is not immediately reused:\n' + + 'orca orchestration worker-release --dispatch --json' + ) + expect(workerLoop).toContain( + 'Acknowledge only after every message and required release decision is handled' + ) + expect(workerLoop).toContain( + 'read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`' + ) + expect(workerLoop).toContain( + 'orca orchestration worker-start --task --terminal --json` so Orca ' + + 'transfers cleanup ownership to the new Dispatch' + ) + expect(workerLoop).toContain( + 'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' + + 'explicitly asked to keep that worker live.' + ) + expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation') + expect(workerLoop).toContain('orca orchestration worker-retain --dispatch --json') + expect(workerLoop).toContain( + 'the same Dispatch can be passed to `worker-release`, which clears the requested retention' + ) + expect(agentGuidance).toContain( + 'Coordinators must account for every settled worker terminal before waiting again or ending ' + + 'the turn' + ) + expect(agentGuidance).toContain('released workers remain readable through `worker-read`') + expect(nextAction).toContain( + 'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' + + 'follow-up Dispatch or run `worker-release` before the next wait.' + ) + }) + + it('never authorizes release from idle, timeout, or worker-side triggers', () => { + const skill = readSkill() + const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop') + const agentGuidance = getSection(skill, 'Agent Guidance') + + // The prohibition sentence is the guard the negative patterns below rely on. + expect(workerLoop).toContain( + 'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' + + 'escalation, or rejected/stale `worker_done`.' + ) + expect(workerLoop).toContain( + 'do not substitute `terminal close`; follow the exact recovery action in the receipt' + ) + expect(skill).not.toMatch( + /release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu + ) + expect(skill).not.toMatch( + /\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu + ) + expect(agentGuidance).toContain( + 'do not start more work, poll, or attempt to close the terminal yourself' + ) + expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu) + }) + it('documents @grok in the Messaging group address list', () => { const skill = readSkill() const messaging = getSection(skill, 'Messaging') diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index e78652d4198..4c790282bb0 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -224,10 +224,18 @@ Wait until every expected Dispatch settles, not for a fixed number of batches: ```bash orca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json -# Process every message in the returned Delivery, then atomically ack and continue: +# Process every message. For each accepted worker_done that is not immediately reused: +orca orchestration worker-release --dispatch --json +# Acknowledge only after every message and required release decision is handled: orca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json ``` +After 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`. + +Run `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. + +Do 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. + Workers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity: ```bash @@ -364,11 +372,12 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding - Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`: `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` - 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. -- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input. +- 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. - For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs: `orca orchestration send --type heartbeat --subject "alive" --payload '{"taskId":"","dispatchId":"","phase":"implementing"}' --json` - If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene. - Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop. +- 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`. - Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps. ## Example @@ -383,6 +392,6 @@ orca orchestration check --wait --types worker_done,escalation,question --timeou ## Next Action -Coordinator: 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. +Coordinator: 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. Worker: 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. diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 748d62dd1a6..0609150c458 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- `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 in the returned Delivery, then atomically ack and continue:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\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 ] [--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. Do not poll or keep calling `orca orchestration check`; the coordinator 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 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.\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- `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 ] [--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/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index 1cafa87cb95..346105d8732 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -109,6 +109,9 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ 'orchestration worker-read', 'orchestration worker-stop', 'orchestration worker-abandon', + 'orchestration worker-release', + 'orchestration worker-retain', + 'orchestration worker-list', 'orchestration dispatch', 'orchestration ask', 'orchestration dispatch-show', diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 0b7ccdb7d6b..f9cbdb8a41a 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -79,6 +79,16 @@ const TASK_STATUS_VALUES = [ 'blocked' ] as const +// Why: mirrors WorkerTerminalListState (orchestration/types.ts) so a bad --terminal-state fails before the RPC. +const WORKER_TERMINAL_LIST_STATES = [ + 'active', + 'reclaimable', + 'retained', + 'release_pending', + 'release_unknown', + 'released' +] as const + type LifecycleSendRejection = { action: 'rejected' code: string @@ -422,6 +432,33 @@ function formatWorkerTranscriptMessage(message: NativeChatMessage): string { return `[${message.role}] ${blocks.join('\n')}`.trimEnd() } +type WorkerReleaseReceipt = { + dispatchId: string + state: string + reason?: string + processAction: string + archive: { source: string | null; status: string | null } | null + recovery?: string + lastError?: string +} + +function formatWorkerRelease(value: WorkerReleaseReceipt): string { + const head = `Worker ${value.dispatchId} terminal [${value.state}]` + const lines = [ + `${head}${value.reason ? ` reason=${value.reason}` : ''} process=${value.processAction}` + ] + if (value.archive) { + lines.push(`archive ${value.archive.source ?? 'none'} [${value.archive.status ?? 'unknown'}]`) + } + if (value.lastError) { + lines.push(value.lastError) + } + if (value.recovery) { + lines.push(value.recovery) + } + return lines.join('\n') +} + function safeJson(value: unknown): string { try { return JSON.stringify(value) @@ -930,6 +967,79 @@ export const ORCHESTRATION_HANDLERS: Record = { ) }, + 'orchestration worker-release': async ({ flags, client, json }) => { + const result = await callMutation( + client, + flags, + 'orchestration.workerRelease', + { dispatch: getRequiredStringFlag(flags, 'dispatch') } + ) + // Why: only an unprovable close is a failure; retained/pending/already-released are settled answers. + if (result.result.state === 'release_unknown') { + process.exitCode = 1 + } + printResult(result, json, formatWorkerRelease) + }, + + 'orchestration worker-retain': async ({ flags, client, json }) => { + const result = await callMutation( + client, + flags, + 'orchestration.workerRetain', + { dispatch: getRequiredStringFlag(flags, 'dispatch') } + ) + if (result.result.state === 'release_unknown') { + process.exitCode = 1 + } + printResult(result, json, formatWorkerRelease) + }, + + 'orchestration worker-list': async ({ flags, client, json }) => { + const terminalState = getOptionalStringFlag(flags, 'terminal-state') + if ( + terminalState && + !WORKER_TERMINAL_LIST_STATES.includes( + terminalState as (typeof WORKER_TERMINAL_LIST_STATES)[number] + ) + ) { + throw new RuntimeClientError( + 'invalid_argument', + `invalid --terminal-state '${terminalState}', expected one of: ${WORKER_TERMINAL_LIST_STATES.join(', ')}` + ) + } + const result = await client.call<{ + workers: { + dispatchId: string + taskId: string + runId: string + workerState: string + dispatchStatus: string + agentTerminalHandle: string | null + terminalState: string | null + resource: unknown + }[] + counts: Record + }>('orchestration.workerList', { + run: getOptionalStringFlag(flags, 'run'), + terminalState + }) + printResult(result, json, (r) => { + if (r.workers.length === 0) { + return 'No workers found.' + } + const rows = r.workers + .map( + (w) => + `${w.dispatchId} task=${w.taskId} [${w.workerState}] terminal=${w.terminalState ?? 'none'}` + ) + .join('\n') + const counts = Object.entries(r.counts) + .map(([state, count]) => `${state}=${count}`) + .join(' ') + return counts ? `${rows}\nTerminals: ${counts}` : rows + }) + }, + 'orchestration dispatch': async ({ flags, client, cwd, json }) => { const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) const dryRun = flags.has('dry-run') ? true : undefined diff --git a/src/cli/help.ts b/src/cli/help.ts index d85a318cb39..2ffa3ecd8df 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -111,6 +111,9 @@ Orchestration: orchestration worker-read Read bounded output from one supervised worker orchestration worker-stop Stop one supervised worker orchestration worker-abandon Fence an uncertain worker without claiming it stopped + orchestration worker-release Release a settled worker's terminal after archiving its output + orchestration worker-retain Keep a worker terminal live for debugging + orchestration worker-list Report worker terminal resource accounting orchestration coordinator-start Start the legacy automatic coordinator loop orchestration coordinator-stop Stop the legacy automatic coordinator loop orchestration gate-create Create a decision gate blocking a task @@ -444,6 +447,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { if (command === 'orchestration worker-read' && flag === 'cursor') { return '--cursor Opaque cursor returned by a previous worker-read page' } + if (command === 'orchestration worker-list' && flag === 'terminal-state') { + return '--terminal-state Terminal accounting filter: active, reclaimable, retained, release_pending, release_unknown, or released' + } if (command === 'linear list-issues' && flag === 'workspace') { return '--workspace Connected Linear workspace id, or all' } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 6eb064bee28..bca1910cb10 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -362,6 +362,15 @@ describe('orca root help', () => { expect(logSpy.mock.calls[0][0]).toContain( 'orchestration worker-abandon Fence an uncertain worker without claiming it stopped' ) + expect(logSpy.mock.calls[0][0]).toContain( + "orchestration worker-release Release a settled worker's terminal after archiving its output" + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'orchestration worker-retain Keep a worker terminal live for debugging' + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'orchestration worker-list Report worker terminal resource accounting' + ) expect(callMock).not.toHaveBeenCalled() }) diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 2b05853e058..a8d8b2411f5 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -67,5 +67,39 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'orca orchestration worker-abandon --dispatch [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'], notes: ['Retains all possibly-live resources and performs no process or filesystem action.'] + }, + { + path: ['orchestration', 'worker-release'], + summary: 'Release the terminal of one settled supervised worker', + usage: + 'orca orchestration worker-release --dispatch [--retry-request ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'], + notes: [ + 'Post-completion cleanup for a settled (succeeded or failed) worker; closes only the exact coordinator-owned agent terminal of that worker.', + 'An inspectable output archive is preserved before the terminal closes, so worker-read still returns output afterwards.', + 'Never closes setup terminals, configured tabs, reused or pre-existing terminals, user-taken-over terminals, or unproven identities.', + 'Idempotent: repeating the call reports already_released. Only release_unknown exits 1; retained, release_pending, and already_released exit 0.' + ] + }, + { + path: ['orchestration', 'worker-retain'], + summary: 'Keep one supervised worker terminal live for debugging', + usage: + 'orca orchestration worker-retain --dispatch [--retry-request ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'], + notes: [ + 'Records a durable user-requested exception; a later explicit worker-release clears it and releases the terminal.', + 'Performs no process or filesystem action.' + ] + }, + { + path: ['orchestration', 'worker-list'], + summary: 'List supervised worker terminal resource accounting', + usage: + 'orca orchestration worker-list [--run ] [--terminal-state ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'run', 'terminal-state'], + notes: [ + 'Terminal state is process accounting and is reported separately from Task status; a completed Task can still own a live terminal.' + ] } ] diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 15ed6e170af..ed1e338c66e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -106,6 +106,7 @@ import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { OrchestrationDb } from './orchestration/db' +import { reconcileRequestedWorkerTerminalReleases } from './orchestration/worker-terminal-release-reconciliation' import { OrchestrationError } from './orchestration/orchestration-error' import { planLegacyWorkerTerminalRecovery, @@ -4503,6 +4504,11 @@ export class OrcaRuntimeService { deferredDispatchIds: [...deferredDispatchIds] } this.updateLegacyWorkerTerminalRecoveryRetry(plan, deferredDispatchIds, options) + // Why: previously requested releases may only finish after the owning provider's terminals + // are rediscovered; this pass runs per scope (local and each reconnected provider). + void reconcileRequestedWorkerTerminalReleases(this).catch((error) => { + console.warn('[orchestration] worker terminal release reconciliation failed', { error }) + }) return result } diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index ddaeb172a72..5cdfccc31e5 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -42,6 +42,15 @@ import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-c import { parsePaneKey } from '../../../shared/stable-pane-id' import { OrchestrationError } from './orchestration-error' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' +import { + deriveWorkerTerminalListState, + type WorkerTerminalResourceRow, + type WorkerTerminalArchiveRow, + type WorkerTerminalArchiveStatus, + type WorkerTerminalListState, + type WorkerTerminalOwnershipState, + type WorkerTerminalRetainedReason +} from './worker-terminal-ownership' import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../shared/orchestration-run-pagination' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' @@ -259,8 +268,8 @@ type RunListCursor = { id: string } -// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup. -const SCHEMA_VERSION = 22 +// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership. +const SCHEMA_VERSION = 23 function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { if (dbPath === ':memory:' || process.platform === 'win32') { @@ -387,6 +396,51 @@ export class OrchestrationDb { updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS worker_terminal_resources ( + id TEXT PRIMARY KEY, + origin_dispatch_id TEXT NOT NULL, + owner_dispatch_id TEXT NOT NULL, + prior_owner_dispatch_ids TEXT NOT NULL DEFAULT '[]', + worktree_id TEXT, + terminal_handle TEXT NOT NULL, + pane_key TEXT, + process_incarnation TEXT, + host_scope TEXT, + ownership_state TEXT NOT NULL DEFAULT 'owned' + CHECK(ownership_state IN ('owned', 'transferred', 'user_owned', 'external', 'released')), + release_state TEXT NOT NULL DEFAULT 'not_requested' + CHECK(release_state IN ( + 'not_requested', 'retained', 'requested', 'releasing', 'released', 'unknown' + )), + retained_reason TEXT, + release_requested_at TEXT, + release_completed_at TEXT, + release_error TEXT, + archive_source TEXT, + archive_status TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_terminal_resources_owner + ON worker_terminal_resources(owner_dispatch_id); + CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_handle + ON worker_terminal_resources(terminal_handle); + CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_pane + ON worker_terminal_resources(pane_key); + CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_identity + ON worker_terminal_resources(process_incarnation, host_scope); + CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_release + ON worker_terminal_resources(release_state); + + CREATE TABLE IF NOT EXISTS worker_terminal_archives ( + dispatch_id TEXT PRIMARY KEY, + resource_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('transcript_pin', 'terminal_tail')), + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS federated_dispatches ( dispatch_id TEXT PRIMARY KEY, environment_id TEXT NOT NULL, @@ -892,6 +946,9 @@ export class OrchestrationDb { ON dispatch_contexts(assignee_handle); `) } + if (current < 23) { + this.backfillWorkerTerminalResources() + } this.createUndeliveredInboxIndexIfPossible() this.db.pragma(`user_version = ${SCHEMA_VERSION}`) @@ -4054,6 +4111,11 @@ export class OrchestrationDb { worktreeId: string effects: unknown[] setupState: string + hostScope?: string | null + // 'created': this worker-start operation created the agent terminal (including agent-first + // worktree creation, whose effects receipt says 'reused_agent_terminal'). 'external': an + // explicit --terminal reuse; ownership transfers only from an exact owned settled resource. + terminalOwnership?: 'created' | 'external' }): string { const dispatch = this.getDispatchContextById(params.dispatchId) const worker = this.getWorkerDispatch(params.dispatchId) @@ -4116,6 +4178,46 @@ export class OrchestrationDb { ), params.dispatchId ) + if (params.terminalOwnership && !this.getWorkerTerminalResourceByOwner(params.dispatchId)) { + if (params.terminalOwnership === 'created') { + this.createWorkerTerminalResourceStatement({ + dispatchId: params.dispatchId, + worktreeId: params.worktreeId, + terminalHandle: params.handle, + paneKey: params.paneKey, + processIncarnation: params.processIncarnation, + hostScope: params.hostScope, + ownership: 'owned' + }) + } else { + const transferable = this.findTransferableWorkerTerminalResource({ + terminalHandle: params.handle, + paneKey: params.paneKey, + processIncarnation: params.processIncarnation, + hostScope: params.hostScope ?? null + }) + if (transferable) { + this.transferWorkerTerminalResourceStatement({ + resourceId: transferable.id, + toDispatchId: params.dispatchId, + terminalHandle: params.handle, + paneKey: params.paneKey, + processIncarnation: params.processIncarnation, + hostScope: params.hostScope ?? null + }) + } else { + this.createWorkerTerminalResourceStatement({ + dispatchId: params.dispatchId, + worktreeId: params.worktreeId, + terminalHandle: params.handle, + paneKey: params.paneKey, + processIncarnation: params.processIncarnation, + hostScope: params.hostScope, + ownership: 'external' + }) + } + } + } this.db.exec('COMMIT') return capability } catch (error) { @@ -5419,6 +5521,568 @@ export class OrchestrationDb { } } + // --- Worker terminal resources (schema v23) --------------------------------------------------- + + // Historical renderer input and reuse cannot be proven, so pre-v23 terminals stay external. + private backfillWorkerTerminalResources(): void { + const rows = this.db + .prepare( + `SELECT w.dispatch_id, w.worktree_id, w.agent_terminal_handle, + d.assignee_pane_key, d.process_incarnation + FROM worker_dispatches w + JOIN dispatch_contexts d ON d.id = w.dispatch_id + WHERE w.agent_terminal_handle IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM federated_dispatches f WHERE f.dispatch_id = w.dispatch_id) + AND NOT EXISTS ( + SELECT 1 FROM worker_terminal_resources r WHERE r.owner_dispatch_id = w.dispatch_id + )` + ) + .all() as { + dispatch_id: string + worktree_id: string | null + agent_terminal_handle: string + assignee_pane_key: string | null + process_incarnation: string | null + }[] + const insert = this.db.prepare( + `INSERT INTO worker_terminal_resources ( + id, origin_dispatch_id, owner_dispatch_id, worktree_id, terminal_handle, + pane_key, process_incarnation, ownership_state, release_state, retained_reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + for (const row of rows) { + insert.run( + generateId('wtr'), + row.dispatch_id, + row.dispatch_id, + row.worktree_id, + row.agent_terminal_handle, + row.assignee_pane_key, + row.process_incarnation, + 'external', + 'retained', + 'legacy_ambiguous' + ) + } + } + + // No transaction: composes inside worker-start's authority transaction. + createWorkerTerminalResourceStatement(params: { + dispatchId: string + worktreeId: string | null + terminalHandle: string + paneKey: string | null + processIncarnation: string | null + hostScope?: string | null + ownership: Extract + }): WorkerTerminalResourceRow { + const id = generateId('wtr') + this.db + .prepare( + `INSERT INTO worker_terminal_resources ( + id, origin_dispatch_id, owner_dispatch_id, worktree_id, terminal_handle, + pane_key, process_incarnation, host_scope, ownership_state, release_state, + retained_reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'not_requested', ?)` + ) + .run( + id, + params.dispatchId, + params.dispatchId, + params.worktreeId, + params.terminalHandle, + params.paneKey, + params.processIncarnation, + params.hostScope ?? null, + params.ownership, + params.ownership === 'external' ? 'external_terminal' : null + ) + return this.getWorkerTerminalResource(id) as WorkerTerminalResourceRow + } + + getWorkerTerminalResource(id: string): WorkerTerminalResourceRow | undefined { + return this.db.prepare('SELECT * FROM worker_terminal_resources WHERE id = ?').get(id) as + | WorkerTerminalResourceRow + | undefined + } + + getWorkerTerminalResourceByOwner(dispatchId: string): WorkerTerminalResourceRow | undefined { + return this.db + .prepare('SELECT * FROM worker_terminal_resources WHERE owner_dispatch_id = ?') + .get(dispatchId) as WorkerTerminalResourceRow | undefined + } + + getWorkerTerminalResourceFormerlyOwnedBy( + dispatchId: string + ): WorkerTerminalResourceRow | undefined { + return this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE prior_owner_dispatch_ids LIKE ? + ORDER BY updated_at DESC LIMIT 1` + ) + .get(`%"${dispatchId}"%`) as WorkerTerminalResourceRow | undefined + } + + // Reusable exact settled terminal: transfers cleanup ownership to the new Dispatch and fences + // release through the old owner. No transaction: composes inside the authority transaction. + transferWorkerTerminalResourceStatement(params: { + resourceId: string + toDispatchId: string + terminalHandle: string + paneKey: string + processIncarnation: string + hostScope: string | null + }): WorkerTerminalResourceRow { + const resource = this.getWorkerTerminalResource(params.resourceId) + if (!resource) { + throw new OrchestrationError( + 'dispatch_not_found', + `Worker terminal resource ${params.resourceId} was not found.` + ) + } + const priorOwners = JSON.parse(resource.prior_owner_dispatch_ids) as string[] + priorOwners.push(resource.owner_dispatch_id) + this.db + .prepare( + `UPDATE worker_terminal_resources + SET owner_dispatch_id = ?, prior_owner_dispatch_ids = ?, release_state = 'not_requested', + retained_reason = NULL, release_requested_at = NULL, release_completed_at = NULL, + release_error = NULL, terminal_handle = ?, pane_key = ?, process_incarnation = ?, + host_scope = ?, updated_at = datetime('now') + WHERE id = ? AND ownership_state = 'owned'` + ) + .run( + params.toDispatchId, + JSON.stringify(priorOwners), + params.terminalHandle, + params.paneKey, + params.processIncarnation, + params.hostScope, + params.resourceId + ) + return this.getWorkerTerminalResource(params.resourceId) as WorkerTerminalResourceRow + } + + // Finds an owned, settled, exact-match resource for an explicitly reused terminal. + findTransferableWorkerTerminalResource(params: { + terminalHandle: string + paneKey: string | null + processIncarnation: string | null + hostScope: string | null + }): WorkerTerminalResourceRow | undefined { + if (!params.paneKey || !params.processIncarnation) { + return undefined + } + const candidates = this.db + .prepare( + `SELECT r.* FROM worker_terminal_resources r + JOIN worker_dispatches w ON w.dispatch_id = r.owner_dispatch_id + WHERE r.process_incarnation = ? AND r.host_scope IS ? + AND r.ownership_state != 'released'` + ) + .all(params.processIncarnation, params.hostScope) as WorkerTerminalResourceRow[] + const exact = candidates.filter( + (candidate) => + candidate.pane_key && + params.paneKey && + isEquivalentPaneKey(candidate.pane_key, params.paneKey) && + candidate.process_incarnation === params.processIncarnation && + candidate.host_scope === params.hostScope + ) + if ( + exact.some((candidate) => + ['requested', 'releasing', 'unknown'].includes(candidate.release_state) + ) + ) { + throw new OrchestrationError( + 'terminal_release_in_progress', + `Terminal ${params.terminalHandle} has a release in progress; wait for cleanup or use another terminal.` + ) + } + return exact.find( + (candidate) => + candidate.ownership_state === 'owned' && + ['not_requested', 'retained'].includes(candidate.release_state) && + ['succeeded', 'failed', 'stopped', 'abandoned'].includes( + this.getWorkerDispatch(candidate.owner_dispatch_id)?.state ?? '' + ) + ) + } + + workerTerminalResourceHasIdentityConflict(resourceId: string): boolean { + const resource = this.getWorkerTerminalResource(resourceId) + if (!resource?.pane_key || !resource.process_incarnation) { + return true + } + const candidates = this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE process_incarnation = ? AND host_scope IS ? + AND id != ? AND ownership_state != 'released'` + ) + .all( + resource.process_incarnation, + resource.host_scope, + resource.id + ) as WorkerTerminalResourceRow[] + return candidates.some( + (candidate) => + candidate.pane_key && + isEquivalentPaneKey(candidate.pane_key, resource.pane_key as string) && + candidate.process_incarnation === resource.process_incarnation && + candidate.host_scope === resource.host_scope + ) + } + + requestWorkerTerminalRelease(dispatchId: string): + | { disposition: 'requested'; resource: WorkerTerminalResourceRow } + | { disposition: 'already_released'; resource: WorkerTerminalResourceRow } + | { + disposition: 'retained' + resource: WorkerTerminalResourceRow | null + reason: WorkerTerminalRetainedReason + } { + this.db.exec('BEGIN IMMEDIATE') + try { + const worker = this.getWorkerDispatch(dispatchId) + if (!worker) { + throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`) + } + if (!['succeeded', 'failed'].includes(worker.state)) { + // Why: release is post-completion cleanup only; recording intent for an unsettled or + // uncertain worker would let recovery close a terminal the coordinator never reviewed. + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${dispatchId} is ${worker.state}; only a succeeded or failed worker can release. Use worker-stop to cancel an active worker.` + ) + } + const resource = this.getWorkerTerminalResourceByOwner(dispatchId) + if (!resource) { + const transferred = this.getWorkerTerminalResourceFormerlyOwnedBy(dispatchId) + this.db.exec('COMMIT') + return transferred + ? { disposition: 'retained', resource: transferred, reason: 'ownership_transferred' } + : { disposition: 'retained', resource: null, reason: 'no_owned_resource' } + } + if (resource.release_state === 'released' || resource.ownership_state === 'released') { + this.db.exec('COMMIT') + return { disposition: 'already_released', resource } + } + if (resource.ownership_state === 'external') { + this.db.exec('COMMIT') + return { + disposition: 'retained', + resource, + reason: (resource.retained_reason as WorkerTerminalRetainedReason) ?? 'external_terminal' + } + } + if (resource.ownership_state === 'user_owned') { + this.db.exec('COMMIT') + return { disposition: 'retained', resource, reason: 'user_takeover' } + } + if (resource.ownership_state === 'transferred') { + this.db.exec('COMMIT') + return { disposition: 'retained', resource, reason: 'ownership_transferred' } + } + if ( + resource.release_state === 'unknown' || + (resource.release_state === 'retained' && resource.retained_reason === 'user_requested') + ) { + this.db + .prepare('DELETE FROM worker_terminal_archives WHERE dispatch_id = ?') + .run(dispatchId) + } + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = CASE + WHEN release_state = 'releasing' THEN 'releasing' + ELSE 'requested' + END, + retained_reason = NULL, + release_requested_at = COALESCE(release_requested_at, datetime('now')), + release_error = NULL, updated_at = datetime('now') + WHERE id = ? AND release_state IN ('not_requested', 'retained', 'requested', 'releasing', 'unknown')` + ) + .run(resource.id) + this.db.exec('COMMIT') + return { + disposition: 'requested', + resource: this.getWorkerTerminalResource(resource.id) as WorkerTerminalResourceRow + } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + storeWorkerTerminalArchive(params: { + dispatchId: string + resourceId: string + kind: 'transcript_pin' | 'terminal_tail' + content: string + }): void { + this.db + .prepare( + `INSERT INTO worker_terminal_archives (dispatch_id, resource_id, kind, content) + VALUES (?, ?, ?, ?) + ON CONFLICT(dispatch_id) DO UPDATE SET + resource_id = excluded.resource_id, kind = excluded.kind, content = excluded.content` + ) + .run(params.dispatchId, params.resourceId, params.kind, params.content) + } + + commitWorkerTerminalArchiveForRelease(params: { + dispatchId: string + resourceId: string + kind?: 'transcript_pin' | 'terminal_tail' + content?: string + archiveSource: 'transcript' | 'terminal' + archiveStatus: Extract + }): WorkerTerminalResourceRow { + this.db.exec('BEGIN IMMEDIATE') + try { + const resource = this.getWorkerTerminalResource(params.resourceId) + if ( + resource?.owner_dispatch_id === params.dispatchId && + resource.ownership_state === 'owned' && + resource.release_state === 'requested' + ) { + if (params.kind && params.content !== undefined) { + this.storeWorkerTerminalArchive({ + dispatchId: params.dispatchId, + resourceId: params.resourceId, + kind: params.kind, + content: params.content + }) + } + const archive = this.getWorkerTerminalArchive(params.dispatchId) + if (!archive || archive.resource_id !== params.resourceId) { + throw new OrchestrationError( + 'archive_failed', + `Output could not be preserved for Dispatch ${params.dispatchId}; the terminal was retained.` + ) + } + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'releasing', archive_source = ?, archive_status = ?, + updated_at = datetime('now') + WHERE id = ? AND owner_dispatch_id = ? AND ownership_state = 'owned' + AND release_state = 'requested'` + ) + .run(params.archiveSource, params.archiveStatus, params.resourceId, params.dispatchId) + } + const updated = this.getWorkerTerminalResource(params.resourceId) as WorkerTerminalResourceRow + this.db.exec('COMMIT') + return updated + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + getWorkerTerminalArchive(dispatchId: string): WorkerTerminalArchiveRow | undefined { + return this.db + .prepare('SELECT * FROM worker_terminal_archives WHERE dispatch_id = ?') + .get(dispatchId) as WorkerTerminalArchiveRow | undefined + } + + settleWorkerTerminalRelease(resourceId: string): WorkerTerminalResourceRow { + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'released', ownership_state = 'released', + release_completed_at = datetime('now'), release_error = NULL, + updated_at = datetime('now') + WHERE id = ? AND release_state IN ('requested', 'releasing', 'unknown')` + ) + .run(resourceId) + return this.getWorkerTerminalResource(resourceId) as WorkerTerminalResourceRow + } + + markWorkerTerminalReleaseUnknown(resourceId: string, reason: string): WorkerTerminalResourceRow { + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'unknown', release_error = ?, updated_at = datetime('now') + WHERE id = ? AND release_state IN ('requested', 'releasing')` + ) + .run(reason, resourceId) + return this.getWorkerTerminalResource(resourceId) as WorkerTerminalResourceRow + } + + revertWorkerTerminalReleaseToRetained( + resourceId: string, + reason: WorkerTerminalRetainedReason + ): WorkerTerminalResourceRow { + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'retained', retained_reason = ?, updated_at = datetime('now') + WHERE id = ? AND release_state IN ('requested', 'releasing')` + ) + .run(reason, resourceId) + return this.getWorkerTerminalResource(resourceId) as WorkerTerminalResourceRow + } + + retainWorkerTerminalResource( + dispatchId: string + ): + | { disposition: 'retained'; resource: WorkerTerminalResourceRow } + | { disposition: 'already_released'; resource: WorkerTerminalResourceRow } + | { disposition: 'release_committed'; resource: WorkerTerminalResourceRow } + | { disposition: 'no_owned_resource'; resource: null } { + this.db.exec('BEGIN IMMEDIATE') + try { + const resource = this.getWorkerTerminalResourceByOwner(dispatchId) + if (!resource) { + this.db.exec('COMMIT') + return { disposition: 'no_owned_resource', resource: null } + } + if (resource.release_state === 'released') { + this.db.exec('COMMIT') + return { disposition: 'already_released', resource } + } + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'retained', retained_reason = 'user_requested', + updated_at = datetime('now') + WHERE id = ? AND release_state IN ('not_requested', 'retained', 'requested')` + ) + .run(resource.id) + const updated = this.getWorkerTerminalResource(resource.id) as WorkerTerminalResourceRow + if (updated.release_state !== 'retained') { + this.db.exec('COMMIT') + return { disposition: 'release_committed', resource: updated } + } + this.db.prepare('DELETE FROM worker_terminal_archives WHERE dispatch_id = ?').run(dispatchId) + this.db.exec('COMMIT') + return { disposition: 'retained', resource: updated } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + // Real user input relinquishes orchestration ownership durably; programmatic prompt delivery, + // query auto-replies, resize, and output never reach this path. + markWorkerTerminalUserOwned(paneKey: string): number { + this.db.exec('BEGIN IMMEDIATE') + try { + const exact = this.db + .prepare( + `SELECT id, owner_dispatch_id, pane_key FROM worker_terminal_resources + WHERE pane_key = ? AND ownership_state = 'owned' + AND release_state IN ('not_requested', 'retained', 'requested')` + ) + .all(paneKey) as { id: string; owner_dispatch_id: string; pane_key: string }[] + const candidates = + exact.length > 0 + ? exact + : ( + this.db + .prepare( + `SELECT id, owner_dispatch_id, pane_key FROM worker_terminal_resources + WHERE ownership_state = 'owned' + AND release_state IN ('not_requested', 'retained', 'requested') + AND pane_key IS NOT NULL` + ) + .all() as { id: string; owner_dispatch_id: string; pane_key: string }[] + ).filter((candidate) => isEquivalentPaneKey(candidate.pane_key, paneKey)) + const update = this.db.prepare( + `UPDATE worker_terminal_resources + SET ownership_state = 'user_owned', release_state = 'retained', + retained_reason = 'user_takeover', updated_at = datetime('now') + WHERE id = ? AND ownership_state = 'owned' + AND release_state IN ('not_requested', 'retained', 'requested')` + ) + let changed = 0 + for (const candidate of candidates) { + const result = Number(update.run(candidate.id).changes) + if (result > 0) { + this.db + .prepare('DELETE FROM worker_terminal_archives WHERE dispatch_id = ?') + .run(candidate.owner_dispatch_id) + changed += result + } + } + this.db.exec('COMMIT') + return changed + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + listWorkerTerminalReleaseBacklog(): WorkerTerminalResourceRow[] { + return this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE release_state IN ('requested', 'releasing') + ORDER BY release_requested_at ASC` + ) + .all() as WorkerTerminalResourceRow[] + } + + listWorkerTerminalResources(params: { runId?: string } = {}): { + dispatchId: string + taskId: string + runId: string + workerState: WorkerDispatchState + dispatchStatus: DispatchStatus + agentTerminalHandle: string | null + terminalState: WorkerTerminalListState | null + resource: WorkerTerminalResourceRow | null + }[] { + const rows = this.db + .prepare( + `SELECT w.dispatch_id, w.state AS worker_state, w.agent_terminal_handle, + d.task_id, d.run_id, d.status AS dispatch_status + FROM worker_dispatches w + JOIN dispatch_contexts d ON d.id = w.dispatch_id + ${params.runId ? 'WHERE d.run_id = ?' : ''} + ORDER BY w.created_at ASC` + ) + .all(...(params.runId ? [params.runId] : [])) as { + dispatch_id: string + worker_state: WorkerDispatchState + agent_terminal_handle: string | null + task_id: string + run_id: string + dispatch_status: DispatchStatus + }[] + const resources = this.db + .prepare( + `SELECT r.* FROM worker_terminal_resources r + JOIN dispatch_contexts d ON d.id = r.owner_dispatch_id + ${params.runId ? 'WHERE d.run_id = ?' : ''}` + ) + .all(...(params.runId ? [params.runId] : [])) as WorkerTerminalResourceRow[] + const resourceByOwner = new Map( + resources.map((resource) => [resource.owner_dispatch_id, resource]) + ) + return rows.map((row) => { + const resource = resourceByOwner.get(row.dispatch_id) ?? null + return { + dispatchId: row.dispatch_id, + taskId: row.task_id, + runId: row.run_id, + workerState: row.worker_state, + dispatchStatus: row.dispatch_status, + agentTerminalHandle: row.agent_terminal_handle, + terminalState: deriveWorkerTerminalListState({ + workerState: row.worker_state, + agentTerminalHandle: row.agent_terminal_handle, + resource + }), + resource + } + }) + } + createDispatchContext( taskId: string, assigneeHandle: string, @@ -6016,6 +6680,8 @@ export class OrchestrationDb { DELETE FROM federation_relay_items; DELETE FROM remote_dispatch_attachments; DELETE FROM federated_dispatches; + DELETE FROM worker_terminal_archives; + DELETE FROM worker_terminal_resources; DELETE FROM worker_dispatches; DELETE FROM dispatch_contexts; DELETE FROM tasks; @@ -6040,6 +6706,8 @@ export class OrchestrationDb { DELETE FROM federation_relay_items; DELETE FROM remote_dispatch_attachments; DELETE FROM federated_dispatches; + DELETE FROM worker_terminal_archives; + DELETE FROM worker_terminal_resources; DELETE FROM worker_dispatches; DELETE FROM dispatch_contexts; DELETE FROM tasks; diff --git a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts index 914321e0646..0b78eaebdd6 100644 --- a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts +++ b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts @@ -234,7 +234,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(22) + expect(sqlite.pragma('user_version', { simple: true })).toBe(23) expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' }) expect( sqlite @@ -254,7 +254,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(22) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(23) expect(db.getDispatchContextById(dispatch.id)).toBeDefined() }) }) diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts new file mode 100644 index 00000000000..c6fe9970feb --- /dev/null +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -0,0 +1,132 @@ +import type { AgentType, NativeChatMessage } from '../../../shared/native-chat-types' +import type { OrcaRuntimeService } from '../orca-runtime' +import { OrchestrationError } from './orchestration-error' +import { + MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT, + redactWorkerTerminalLines +} from './worker-transcript-payload' +import { readWorkerTranscript } from './worker-transcript-read' + +// Bound the durable copy of raw terminal output; the tail end is the evidence that matters. +const TERMINAL_ARCHIVE_MAX_CHARS = 262_144 + +export type WorkerTranscriptPinArchive = { + agent: AgentType + providerSessionKey: string + providerSessionId: string + transcriptPath: string | null + processIncarnation: string + observedAfter: number + endOffset?: number +} + +export type WorkerTranscriptSnapshotArchive = { + version: 2 + agent: AgentType + processIncarnation: string + messages: NativeChatMessage[] + limited: boolean + warnings: string[] +} + +export type WorkerTerminalTailArchive = { + lines: string[] + truncated: boolean + terminalStatus: string + warnings: string[] +} + +export type WorkerOutputArchiveCapture = + | { + kind: 'transcript_pin' + content: WorkerTranscriptSnapshotArchive + status: 'captured' + } + | { kind: 'terminal_tail'; content: WorkerTerminalTailArchive; status: 'captured' | 'empty' } + +// Freezes an inspectable output source before the live PTY is closed. Prefers the exact +// hook-reported provider transcript; falls back to bounded redacted terminal output. Throws +// typed archive_failed so release retains the live terminal when no evidence can be preserved. +export async function captureWorkerOutputArchive(args: { + runtime: OrcaRuntimeService + dispatchId: string + terminalHandle: string + attachedAtMs: number +}): Promise { + const session = args.runtime.getExactWorkerProviderSession(args.terminalHandle, args.attachedAtMs) + if (session) { + const snapshot = await readWorkerTranscript({ + agent: session.agent, + sessionId: session.providerSession.id, + transcriptPath: session.providerSession.transcriptPath, + limit: MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT + }).catch(() => null) + if (snapshot?.ok && snapshot.messages.length > 0) { + return { + kind: 'transcript_pin', + status: 'captured', + content: { + version: 2, + agent: session.agent, + processIncarnation: session.processIncarnation, + messages: snapshot.messages, + limited: snapshot.limited, + warnings: snapshot.warnings + } + } + } + } + let terminal + try { + terminal = await args.runtime.readTerminal(args.terminalHandle, {}) + } catch (error) { + throw new OrchestrationError( + 'archive_failed', + `Output could not be preserved for Dispatch ${args.dispatchId}; the terminal was retained. ${error instanceof Error ? error.message : String(error)}` + ) + } + const redacted = redactWorkerTerminalLines(terminal.tail) + const bounded = boundArchiveLines(redacted.lines) + // Why: an exited PTY zeroes its tail immediately, so an empty capture is a distinct receipt, + // not silent success — worker-read must be able to say why nothing is there. + const empty = bounded.lines.every((line) => line.trim() === '') + return { + kind: 'terminal_tail', + status: empty ? 'empty' : 'captured', + content: { + lines: bounded.lines, + truncated: terminal.truncated || bounded.truncated, + terminalStatus: terminal.status, + warnings: empty + ? [ + ...redacted.warnings, + 'The live terminal buffer was empty at release; structured transcript output was unavailable.' + ] + : redacted.warnings + } + } +} + +function boundArchiveLines(lines: string[]): { lines: string[]; truncated: boolean } { + let total = 0 + for (const line of lines) { + total += line.length + 1 + } + if (total <= TERMINAL_ARCHIVE_MAX_CHARS) { + return { lines, truncated: false } + } + const kept: string[] = [] + let budget = TERMINAL_ARCHIVE_MAX_CHARS + for (let index = lines.length - 1; index >= 0; index -= 1) { + const cost = lines[index].length + 1 + if (cost > budget) { + if (kept.length === 0 && budget > 1) { + kept.unshift(lines[index].slice(-(budget - 1))) + } + break + } + kept.unshift(lines[index]) + budget -= cost + } + return { lines: kept, truncated: true } +} diff --git a/src/main/runtime/orchestration/worker-terminal-ownership.ts b/src/main/runtime/orchestration/worker-terminal-ownership.ts new file mode 100644 index 00000000000..d6f160cda4a --- /dev/null +++ b/src/main/runtime/orchestration/worker-terminal-ownership.ts @@ -0,0 +1,134 @@ +import type { WorkerDispatchState } from './types' + +export type WorkerTerminalOwnershipState = + | 'owned' + | 'transferred' + | 'user_owned' + | 'external' + | 'released' + +export type WorkerTerminalReleaseState = + | 'not_requested' + | 'retained' + | 'requested' + | 'releasing' + | 'released' + | 'unknown' + +export type WorkerTerminalRetainedReason = + | 'external_terminal' + | 'ownership_transferred' + | 'user_takeover' + | 'user_requested' + | 'no_owned_resource' + | 'identity_unproven' + | 'legacy_ambiguous' + | 'federation_unsupported' + +export type WorkerTerminalArchiveStatus = 'captured' | 'empty' | 'unavailable' + +export type WorkerTerminalResourceRow = { + id: string + origin_dispatch_id: string + owner_dispatch_id: string + prior_owner_dispatch_ids: string + worktree_id: string | null + terminal_handle: string + pane_key: string | null + process_incarnation: string | null + host_scope: string | null + ownership_state: WorkerTerminalOwnershipState + release_state: WorkerTerminalReleaseState + retained_reason: string | null + release_requested_at: string | null + release_completed_at: string | null + release_error: string | null + archive_source: string | null + archive_status: WorkerTerminalArchiveStatus | null + created_at: string + updated_at: string +} + +// Terminal state exposed by worker-list; process accounting, never Task/Dispatch outcome. +export type WorkerTerminalListState = + | 'active' + | 'reclaimable' + | 'retained' + | 'release_pending' + | 'release_unknown' + | 'released' + +export type WorkerTerminalArchiveRow = { + dispatch_id: string + resource_id: string + kind: 'transcript_pin' | 'terminal_tail' + content: string + created_at: string +} + +export const WORKER_SETTLED_STATES: readonly WorkerDispatchState[] = [ + 'succeeded', + 'failed', + 'stopped', + 'abandoned' +] + +export const WORKER_RELEASABLE_STATES: readonly WorkerDispatchState[] = ['succeeded', 'failed'] + +// Same-operation creation evidence: worker-start labels its own agent terminal 'created' +// (existing worktree) or 'reused_agent_terminal' (agent-first worktree creation). An explicit +// --terminal reuse is labeled 'reused' and never claims ownership. +export function residualResourcesClaimAgentTerminal( + residualResourcesJson: string, + terminalHandle: string +): boolean { + let parsed: unknown + try { + parsed = JSON.parse(residualResourcesJson) + } catch { + return false + } + if (!Array.isArray(parsed)) { + return false + } + return parsed.some((effect) => { + if (!effect || typeof effect !== 'object') { + return false + } + const candidate = effect as { kind?: unknown; role?: unknown; action?: unknown; id?: unknown } + return ( + candidate.kind === 'terminal' && + candidate.role === 'agent' && + candidate.id === terminalHandle && + (candidate.action === 'created' || candidate.action === 'reused_agent_terminal') + ) + }) +} + +// Process accounting for worker-list; deliberately independent of Task/Dispatch outcome. +export function deriveWorkerTerminalListState(params: { + workerState: WorkerDispatchState + agentTerminalHandle: string | null + resource: WorkerTerminalResourceRow | null +}): WorkerTerminalListState | null { + const { resource } = params + if (!resource) { + return params.agentTerminalHandle ? 'retained' : null + } + if (resource.release_state === 'released') { + return 'released' + } + if (resource.release_state === 'unknown') { + return 'release_unknown' + } + if (resource.release_state === 'requested' || resource.release_state === 'releasing') { + return 'release_pending' + } + if (resource.ownership_state !== 'owned' || resource.release_state === 'retained') { + return 'retained' + } + if (WORKER_RELEASABLE_STATES.includes(params.workerState)) { + return 'reclaimable' + } + return WORKER_SETTLED_STATES.includes(params.workerState) ? 'retained' : 'active' +} diff --git a/src/main/runtime/orchestration/worker-terminal-release-reconciliation.ts b/src/main/runtime/orchestration/worker-terminal-release-reconciliation.ts new file mode 100644 index 00000000000..86a10ed06de --- /dev/null +++ b/src/main/runtime/orchestration/worker-terminal-release-reconciliation.ts @@ -0,0 +1,101 @@ +import type { OrcaRuntimeService } from '../orca-runtime' +import { completeWorkerTerminalRelease } from '../rpc/methods/orchestration-worker-release-completion' + +export type WorkerTerminalReleaseReconciliationResult = { + attempted: number + released: number + pending: number + unknown: number + retained: number +} + +type ActiveReconciliation = { + rerunRequested: boolean + promise?: Promise +} + +const activeReconciliationByRuntime = new WeakMap() + +// Finishes ONLY previously requested releases after startup/reconnect terminal discovery. +// It never invents release intent: resources outside requested/releasing are untouched, and +// unresolved identity defers (release_pending) rather than settling or broadening the close. +export function reconcileRequestedWorkerTerminalReleases( + runtime: OrcaRuntimeService +): Promise { + const active = activeReconciliationByRuntime.get(runtime) + if (active?.promise) { + active.rerunRequested = true + return active.promise + } + const state: ActiveReconciliation = { rerunRequested: false } + const reconciliation = runReconciliationPasses(runtime, state).finally(() => { + if (activeReconciliationByRuntime.get(runtime) === state) { + activeReconciliationByRuntime.delete(runtime) + } + }) + state.promise = reconciliation + activeReconciliationByRuntime.set(runtime, state) + return reconciliation +} + +async function runReconciliationPasses( + runtime: OrcaRuntimeService, + state: ActiveReconciliation +): Promise { + const combined = emptyResult() + do { + state.rerunRequested = false + const pass = await reconcileRequestedWorkerTerminalReleasesOnce(runtime) + combined.attempted += pass.attempted + combined.released += pass.released + combined.pending += pass.pending + combined.unknown += pass.unknown + combined.retained += pass.retained + } while (state.rerunRequested) + if (combined.attempted > 0) { + // Structured counts only; never transcript content or paths. + console.info('[orchestration] worker terminal release reconciliation', combined) + } + return combined +} + +async function reconcileRequestedWorkerTerminalReleasesOnce( + runtime: OrcaRuntimeService +): Promise { + const db = runtime.getOrchestrationDb() + const backlog = db.listWorkerTerminalReleaseBacklog() + const result = { ...emptyResult(), attempted: backlog.length } + for (const resource of backlog) { + try { + const receipt = await completeWorkerTerminalRelease({ + runtime, + db, + dispatchId: resource.owner_dispatch_id, + resource, + mode: 'recovery' + }) + if (receipt.state === 'released' || receipt.state === 'already_released') { + result.released += 1 + } else if (receipt.state === 'release_pending') { + result.pending += 1 + } else if (receipt.state === 'release_unknown') { + result.unknown += 1 + } else { + result.retained += 1 + } + } catch (error) { + // Archive or endpoint failure: durable intent stays requested; a later discovery retries. + console.warn('[orchestration] worker terminal release retry failed', { + resourceId: resource.id, + dispatchId: resource.owner_dispatch_id, + error: error instanceof Error ? error.message : String(error) + }) + result.pending += 1 + } + } + return result +} + +function emptyResult(): WorkerTerminalReleaseReconciliationResult { + return { attempted: 0, released: 0, pending: 0, unknown: 0, retained: 0 } +} diff --git a/src/main/runtime/orchestration/worker-transcript-read.test.ts b/src/main/runtime/orchestration/worker-transcript-read.test.ts index 0bcceda11f5..48500734a5f 100644 --- a/src/main/runtime/orchestration/worker-transcript-read.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-read.test.ts @@ -77,6 +77,49 @@ describe('worker transcript reads', () => { }) }) + it('pins archived reads to the transcript offset observed before release', async () => { + await writeFile( + transcriptPath, + `${codexMessage('one', 'before release')}\n${codexMessage('two', 'release boundary')}\n` + ) + const snapshot = await readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + limit: 1 + }) + if (!snapshot.ok) { + throw new Error('Expected the release transcript probe') + } + await appendFile(transcriptPath, `${codexMessage('three', 'after release')}\n`) + + await expect( + readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + endOffset: snapshot.nextOffset, + limit: 10 + }) + ).resolves.toMatchObject({ + ok: true, + messages: [ + { id: 'one', blocks: [{ type: 'text', text: 'before release' }] }, + { id: 'two', blocks: [{ type: 'text', text: 'release boundary' }] } + ] + }) + await expect( + readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + offset: snapshot.nextOffset, + endOffset: snapshot.nextOffset, + limit: 10 + }) + ).resolves.toMatchObject({ ok: true, messages: [], nextOffset: snapshot.nextOffset }) + }) + it('reports source changes and unsupported providers without guessing', async () => { await writeFile(transcriptPath, `${codexMessage('one', 'first')}\n`) diff --git a/src/main/runtime/orchestration/worker-transcript-read.ts b/src/main/runtime/orchestration/worker-transcript-read.ts index d893c35f995..f63e073b825 100644 --- a/src/main/runtime/orchestration/worker-transcript-read.ts +++ b/src/main/runtime/orchestration/worker-transcript-read.ts @@ -39,6 +39,7 @@ export async function readWorkerTranscript(args: { sessionId: string transcriptPath?: string offset?: number + endOffset?: number limit?: number }): Promise { const transcriptAgent = resolveNativeChatTranscriptAgent(args.agent) @@ -64,8 +65,8 @@ export async function readWorkerTranscript(args: { try { const page = args.offset === undefined - ? await readInitialPage(filePath, limit, decode) - : await readForwardPage(filePath, args.offset, limit, decode) + ? await readInitialPage(filePath, limit, decode, args.endOffset) + : await readForwardPage(filePath, args.offset, limit, decode, args.endOffset) if (!page.ok) { return page } @@ -96,9 +97,13 @@ export async function readWorkerTranscript(args: { async function readInitialPage( filePath: string, limit: number, - decode: NativeChatLineDecoder -): Promise { - const page = await readNativeChatTranscriptTailFile(filePath, limit, decode, false) + decode: NativeChatLineDecoder, + endOffset?: number +): Promise { + if (endOffset !== undefined && (await stat(filePath)).size < endOffset) { + return { ok: false, reason: 'source_changed', warnings: [] } + } + const page = await readNativeChatTranscriptTailFile(filePath, limit, decode, false, endOffset) return { ok: true, filePath, @@ -113,9 +118,14 @@ async function readForwardPage( filePath: string, startOffset: number, limit: number, - decode: NativeChatLineDecoder + decode: NativeChatLineDecoder, + endOffset?: number ): Promise { - const fileSize = (await stat(filePath)).size + const currentFileSize = (await stat(filePath)).size + if (endOffset !== undefined && currentFileSize < endOffset) { + return { ok: false, reason: 'source_changed', warnings: [] } + } + const fileSize = Math.min(currentFileSize, endOffset ?? Number.MAX_SAFE_INTEGER) if (startOffset > fileSize) { return { ok: false, reason: 'source_changed', warnings: [] } } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts b/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts new file mode 100644 index 00000000000..29e01e45558 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts @@ -0,0 +1,226 @@ +import type { + OrchestrationWorkerReadResult, + OrchestrationWorkerReadSource +} from '../../../../shared/orchestration-worker-output' +import type { OrchestrationDb } from '../../orchestration/db' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { + WorkerTerminalArchiveRow, + WorkerTerminalResourceRow +} from '../../orchestration/worker-terminal-ownership' +import type { + WorkerTerminalTailArchive, + WorkerTranscriptPinArchive, + WorkerTranscriptSnapshotArchive +} from '../../orchestration/worker-output-archive' +import { clampWorkerTranscriptLimit } from '../../orchestration/worker-transcript-payload' +import { + createWorkerOutputSourceIdentity, + decodeWorkerOutputCursor, + encodeWorkerOutputCursor +} from '../../orchestration/worker-output-cursor' +import { readWorkerTranscript } from '../../orchestration/worker-transcript-read' + +const ARCHIVED_TERMINAL_PAGE_LINES = 2_000 + +// Serves the frozen output source after the live PTY is gone. Transcript pins read the exact +// provider transcript directly; terminal archives page the stored redacted tail. Cursors stay +// Dispatch-scoped and source-pinned exactly like live reads. +export async function readArchivedWorkerOutput(args: { + db: OrchestrationDb + dispatchId: string + workerState: string + resource: WorkerTerminalResourceRow + source?: OrchestrationWorkerReadSource + cursor?: string | number + limit?: number +}): Promise { + const archive = args.db.getWorkerTerminalArchive(args.dispatchId) + if (!archive) { + throw new OrchestrationError( + 'archive_unavailable', + `Dispatch ${args.dispatchId} was released without a preserved output archive.` + ) + } + if (archive.kind === 'transcript_pin') { + if (args.source === 'terminal') { + throw new OrchestrationError( + 'archive_unavailable', + `Dispatch ${args.dispatchId} preserved structured transcript output only; terminal output was released.` + ) + } + const content = JSON.parse(archive.content) as + | WorkerTranscriptPinArchive + | WorkerTranscriptSnapshotArchive + return isTranscriptSnapshot(content) + ? readFrozenTranscript(args, archive, content) + : readLegacyPinnedTranscript(args, content) + } + if (args.source === 'transcript') { + throw new OrchestrationError( + 'transcript_required', + `Structured output is unavailable for released Dispatch ${args.dispatchId}: the archive holds terminal output only.` + ) + } + return readArchivedTerminalTail(args, archive) +} + +function readFrozenTranscript( + args: Parameters[0], + archive: WorkerTerminalArchiveRow, + snapshot: WorkerTranscriptSnapshotArchive +): OrchestrationWorkerReadResult { + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'released-transcript-snapshot', + args.resource.id, + snapshot.processIncarnation, + archive.created_at + ]) + if (cursor && (cursor.source !== 'transcript' || cursor.sourceIdentity !== sourceIdentity)) { + throw sourceChanged() + } + const start = Math.min(cursor?.position ?? 0, snapshot.messages.length) + const end = Math.min(start + clampWorkerTranscriptLimit(args.limit), snapshot.messages.length) + const nextCursor = encodeWorkerOutputCursor(args.dispatchId, 'transcript', sourceIdentity, end) + return { + dispatchId: args.dispatchId, + source: 'transcript', + sourceIdentity, + provider: snapshot.agent, + transcript: { + messages: snapshot.messages.slice(start, end), + nextCursor, + limited: end < snapshot.messages.length, + returnedMessageCount: end - start + }, + cursor: nextCursor, + status: { worker: args.workerState, terminal: 'exited' }, + fallbackReason: null, + warnings: [ + ...snapshot.warnings, + ...(snapshot.limited + ? ['Older transcript messages were omitted from the bounded archive.'] + : []) + ], + archived: true + } +} + +async function readLegacyPinnedTranscript( + args: Parameters[0], + pin: WorkerTranscriptPinArchive +): Promise { + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'released-transcript', + pin.processIncarnation, + pin.agent, + pin.providerSessionKey, + pin.providerSessionId, + pin.transcriptPath ?? '', + String(pin.endOffset) + ]) + if (cursor && cursor.source !== 'transcript') { + throw sourceChanged() + } + if (cursor && cursor.sourceIdentity !== sourceIdentity) { + throw sourceChanged() + } + const transcript = await readWorkerTranscript({ + agent: pin.agent, + sessionId: pin.providerSessionId, + transcriptPath: pin.transcriptPath ?? undefined, + offset: cursor?.position, + endOffset: pin.endOffset, + limit: args.limit + }) + if (!transcript.ok) { + throw new OrchestrationError( + 'transcript_required', + `The pinned transcript for released Dispatch ${args.dispatchId} is unavailable: ${transcript.reason}.`, + { reason: transcript.reason } + ) + } + const nextCursor = encodeWorkerOutputCursor( + args.dispatchId, + 'transcript', + sourceIdentity, + transcript.nextOffset + ) + return { + dispatchId: args.dispatchId, + source: 'transcript', + sourceIdentity, + provider: pin.agent, + transcript: { + messages: transcript.messages, + nextCursor, + limited: transcript.limited, + returnedMessageCount: transcript.messages.length + }, + cursor: nextCursor, + status: { worker: args.workerState, terminal: 'exited' }, + fallbackReason: null, + warnings: transcript.warnings, + archived: true + } +} + +function isTranscriptSnapshot( + content: WorkerTranscriptPinArchive | WorkerTranscriptSnapshotArchive +): content is WorkerTranscriptSnapshotArchive { + return 'version' in content && content.version === 2 +} + +function readArchivedTerminalTail( + args: Parameters[0], + archive: WorkerTerminalArchiveRow +): OrchestrationWorkerReadResult { + const content = JSON.parse(archive.content) as WorkerTerminalTailArchive + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'released-terminal', + args.resource.id, + archive.created_at + ]) + if (cursor && cursor.source !== 'terminal') { + throw sourceChanged() + } + if (cursor && (cursor.legacy || cursor.sourceIdentity !== sourceIdentity)) { + throw sourceChanged() + } + const start = Math.min(cursor?.position ?? 0, content.lines.length) + const pageSize = Math.max(1, Math.min(args.limit ?? ARCHIVED_TERMINAL_PAGE_LINES, 10_000)) + const end = Math.min(start + pageSize, content.lines.length) + const tail = content.lines.slice(start, end) + const nextCursor = + end < content.lines.length + ? encodeWorkerOutputCursor(args.dispatchId, 'terminal', sourceIdentity, end) + : null + return { + dispatchId: args.dispatchId, + source: 'terminal', + sourceIdentity, + terminal: { + handle: args.resource.terminal_handle, + status: 'exited', + tail, + truncated: content.truncated, + nextCursor, + returnedLineCount: tail.length + }, + cursor: nextCursor, + status: { worker: args.workerState, terminal: 'exited' }, + fallbackReason: null, + warnings: content.warnings, + archived: true + } +} + +function sourceChanged(): OrchestrationError { + return new OrchestrationError( + 'source_changed', + 'The worker output source changed. Start a fresh worker-read without the old cursor.' + ) +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-control.ts b/src/main/runtime/rpc/methods/orchestration-worker-control.ts index ae64f967e06..23233b63965 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-control.ts @@ -3,14 +3,8 @@ import { ORCHESTRATION_WORKER_READ_SOURCES, type OrchestrationWorkerReadResult } from '../../../../shared/orchestration-worker-output' -import type { RuntimeTerminalRead } from '../../../../shared/runtime-types' import { OrchestrationError } from '../../orchestration/orchestration-error' import { syncFederatedDispatch } from '../../orchestration/federation-sync' -import { - createWorkerOutputSourceIdentity, - decodeWorkerOutputCursor, - encodeWorkerOutputCursor -} from '../../orchestration/worker-output-cursor' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, requiredString } from '../schemas' import { @@ -19,7 +13,10 @@ import { inspectWorkerTerminal, resolvePinnedFederatedServer } from './orchestration-worker-observation' +import { readArchivedWorkerOutput } from './orchestration-worker-archive-read' +import { readLegacyFederatedTerminal } from './orchestration-worker-legacy-federated-read' import { readExactWorkerOutput } from './orchestration-worker-output' +import { exposeWorkerTerminalResource } from './orchestration-worker-release-completion' const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) const WorkerReadParams = WorkerDispatchParams.extend({ @@ -119,11 +116,13 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ } } const observation = await inspectWorkerTerminal(runtime, db, params.dispatch) + const resource = db.getWorkerTerminalResourceByOwner(params.dispatch) return { dispatch, worker: exposeWorker(worker), terminal: observation.exact ? observation.terminal : null, - observation: { status: observation.status, exactWorker: observation.exact } + observation: { status: observation.status, exactWorker: observation.exact }, + terminalResource: resource ? exposeWorkerTerminalResource(resource) : null } } }), @@ -175,6 +174,18 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ `Worker Dispatch ${params.dispatch} has no agent terminal.` ) } + const resource = db.getWorkerTerminalResourceByOwner(params.dispatch) + if (resource && ['releasing', 'released'].includes(resource.release_state)) { + return readArchivedWorkerOutput({ + db, + dispatchId: params.dispatch, + workerState: worker.state, + resource, + source: params.source, + cursor: params.cursor, + limit: params.limit + }) + } const observation = await inspectWorkerTerminal(runtime, db, params.dispatch) if (!observation.exact) { throw new OrchestrationError( @@ -227,68 +238,3 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ } }) ] - -async function readLegacyFederatedTerminal(args: { - runtime: Parameters[0] - server: ReturnType - federated: Parameters[1] - workerState: string - dispatchId: string - source: (typeof ORCHESTRATION_WORKER_READ_SOURCES)[number] | undefined - cursor: string | number | undefined - limit: number | undefined -}) { - const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) - if (args.source === 'transcript' || cursor?.source === 'transcript') { - throw new OrchestrationError( - 'transcript_required', - `Connected server ${args.server.name} does not support structured worker output.`, - { reason: 'remote_capability_unavailable' } - ) - } - const remote = (await args.runtime.callOrchestrationWorkerServer( - args.server.environmentId, - 'orchestration.federationRead', - { - dispatchId: args.dispatchId, - cursor: cursor?.source === 'terminal' ? cursor.position : undefined, - limit: args.limit - }, - 15_000 - )) as { runtimeEpoch: string; terminal: RuntimeTerminalRead } - const sourceIdentity = createWorkerOutputSourceIdentity([ - 'legacy-remote-terminal', - args.federated.peer_fingerprint, - args.dispatchId, - remote.runtimeEpoch - ]) - if ( - cursor?.source === 'terminal' && - cursor.sourceIdentity !== null && - cursor.sourceIdentity !== sourceIdentity - ) { - throw new OrchestrationError( - 'source_changed', - 'The worker output source changed. Start a fresh worker-read without the old cursor.' - ) - } - const nextPosition = - remote.terminal.nextCursor !== null && /^\d+$/.test(remote.terminal.nextCursor) - ? Number.parseInt(remote.terminal.nextCursor, 10) - : null - return { - dispatchId: args.dispatchId, - source: 'terminal' as const, - sourceIdentity, - terminal: remote.terminal, - cursor: - nextPosition === null - ? null - : encodeWorkerOutputCursor(args.dispatchId, 'terminal', sourceIdentity, nextPosition), - status: { worker: args.workerState, terminal: remote.terminal.status }, - fallbackReason: 'remote_capability_unavailable' as const, - warnings: [], - server: { environmentId: args.server.environmentId, name: args.server.name }, - remoteRuntimeEpoch: remote.runtimeEpoch - } -} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-legacy-federated-read.ts b/src/main/runtime/rpc/methods/orchestration-worker-legacy-federated-read.ts new file mode 100644 index 00000000000..87935f07173 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-legacy-federated-read.ts @@ -0,0 +1,76 @@ +import type { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../shared/orchestration-worker-output' +import type { RuntimeTerminalRead } from '../../../../shared/runtime-types' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { + createWorkerOutputSourceIdentity, + decodeWorkerOutputCursor, + encodeWorkerOutputCursor +} from '../../orchestration/worker-output-cursor' +import type { resolvePinnedFederatedServer } from './orchestration-worker-observation' + +// Pre-structured-output servers only expose raw terminal reads; keep that path fenced and +// cursor-scoped so an old peer never silently degrades a transcript cursor. +export async function readLegacyFederatedTerminal(args: { + runtime: Parameters[0] + server: ReturnType + federated: Parameters[1] + workerState: string + dispatchId: string + source: (typeof ORCHESTRATION_WORKER_READ_SOURCES)[number] | undefined + cursor: string | number | undefined + limit: number | undefined +}) { + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + if (args.source === 'transcript' || cursor?.source === 'transcript') { + throw new OrchestrationError( + 'transcript_required', + `Connected server ${args.server.name} does not support structured worker output.`, + { reason: 'remote_capability_unavailable' } + ) + } + const remote = (await args.runtime.callOrchestrationWorkerServer( + args.server.environmentId, + 'orchestration.federationRead', + { + dispatchId: args.dispatchId, + cursor: cursor?.source === 'terminal' ? cursor.position : undefined, + limit: args.limit + }, + 15_000 + )) as { runtimeEpoch: string; terminal: RuntimeTerminalRead } + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'legacy-remote-terminal', + args.federated.peer_fingerprint, + args.dispatchId, + remote.runtimeEpoch + ]) + if ( + cursor?.source === 'terminal' && + cursor.sourceIdentity !== null && + cursor.sourceIdentity !== sourceIdentity + ) { + throw new OrchestrationError( + 'source_changed', + 'The worker output source changed. Start a fresh worker-read without the old cursor.' + ) + } + const nextPosition = + remote.terminal.nextCursor !== null && /^\d+$/.test(remote.terminal.nextCursor) + ? Number.parseInt(remote.terminal.nextCursor, 10) + : null + return { + dispatchId: args.dispatchId, + source: 'terminal' as const, + sourceIdentity, + terminal: remote.terminal, + cursor: + nextPosition === null + ? null + : encodeWorkerOutputCursor(args.dispatchId, 'terminal', sourceIdentity, nextPosition), + status: { worker: args.workerState, terminal: remote.terminal.status }, + fallbackReason: 'remote_capability_unavailable' as const, + warnings: [], + server: { environmentId: args.server.environmentId, name: args.server.name }, + remoteRuntimeEpoch: remote.runtimeEpoch + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-methods.ts b/src/main/runtime/rpc/methods/orchestration-worker-methods.ts index 341521323fd..c613732d263 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-methods.ts @@ -1,10 +1,12 @@ import type { RpcMethod } from '../core' import { ORCHESTRATION_WORKER_CONTROL_METHODS } from './orchestration-worker-control' +import { ORCHESTRATION_WORKER_RELEASE_METHODS } from './orchestration-worker-release' import { ORCHESTRATION_WORKER_STOP_METHODS } from './orchestration-worker-stop' import { ORCHESTRATION_WORKER_START_METHODS } from './orchestration-workers' export const ORCHESTRATION_WORKER_METHODS: RpcMethod[] = [ ...ORCHESTRATION_WORKER_START_METHODS, ...ORCHESTRATION_WORKER_CONTROL_METHODS, - ...ORCHESTRATION_WORKER_STOP_METHODS + ...ORCHESTRATION_WORKER_STOP_METHODS, + ...ORCHESTRATION_WORKER_RELEASE_METHODS ] diff --git a/src/main/runtime/rpc/methods/orchestration-worker-output.ts b/src/main/runtime/rpc/methods/orchestration-worker-output.ts index 60e3f942839..2b222f01586 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-output.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-output.ts @@ -181,7 +181,7 @@ function assertCursorSourceMatchesRequest( } } -function orchestrationTimestampToMs(value: string): number { +export function orchestrationTimestampToMs(value: string): number { const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(value) ? `${value.replace(' ', 'T')}Z` : value diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-completion.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-completion.ts new file mode 100644 index 00000000000..4beb0323fd1 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-completion.ts @@ -0,0 +1,292 @@ +import type { OrchestrationDb } from '../../orchestration/db' +import type { + WorkerTerminalArchiveRow, + WorkerTerminalArchiveStatus, + WorkerTerminalResourceRow, + WorkerTerminalRetainedReason +} from '../../orchestration/worker-terminal-ownership' +import { + captureWorkerOutputArchive, + type WorkerTerminalTailArchive +} from '../../orchestration/worker-output-archive' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { inspectWorkerTerminal } from './orchestration-worker-observation' +import { orchestrationTimestampToMs } from './orchestration-worker-output' + +export type WorkerReleaseReceipt = { + dispatchId: string + state: 'released' | 'already_released' | 'retained' | 'release_pending' | 'release_unknown' + reason?: WorkerTerminalRetainedReason + processAction: 'closed_agent_terminal' | 'closed_exited_terminal' | 'none' + archive: { source: string | null; status: string | null } | null + recovery?: string + lastError?: string +} + +type WorkerTerminalReleaseArgs = { + runtime: OrcaRuntimeService + db: OrchestrationDb + dispatchId: string + resource: WorkerTerminalResourceRow + mode?: 'interactive' | 'recovery' +} + +const activeReleaseByRuntime = new WeakMap< + OrcaRuntimeService, + Map> +>() + +export function exposeWorkerTerminalResource(resource: WorkerTerminalResourceRow): { + id: string + ownershipState: string + releaseState: string + retainedReason: string | null + terminalHandle: string + worktreeId: string | null + originDispatchId: string + ownerDispatchId: string + releaseRequestedAt: string | null + releaseCompletedAt: string | null + releaseError: string | null + archive: { source: string | null; status: string | null } +} { + return { + id: resource.id, + ownershipState: resource.ownership_state, + releaseState: resource.release_state, + retainedReason: resource.retained_reason, + terminalHandle: resource.terminal_handle, + worktreeId: resource.worktree_id, + originDispatchId: resource.origin_dispatch_id, + ownerDispatchId: resource.owner_dispatch_id, + releaseRequestedAt: resource.release_requested_at, + releaseCompletedAt: resource.release_completed_at, + releaseError: resource.release_error, + archive: { source: resource.archive_source, status: resource.archive_status } + } +} + +export function archiveSummary( + resource: WorkerTerminalResourceRow | null +): { source: string | null; status: string | null } | null { + if (!resource) { + return null + } + if (!resource.archive_source && !resource.archive_status) { + return null + } + return { source: resource.archive_source, status: resource.archive_status } +} + +// Completes a durably requested release: re-prove exact identity, freeze output, close only the +// exact agent terminal, settle. Shared between the RPC method and the startup reconciler. +export function completeWorkerTerminalRelease( + args: WorkerTerminalReleaseArgs +): Promise { + let activeByResource = activeReleaseByRuntime.get(args.runtime) + if (!activeByResource) { + activeByResource = new Map() + activeReleaseByRuntime.set(args.runtime, activeByResource) + } + const active = activeByResource.get(args.resource.id) + if (active) { + return active + } + const release = completeWorkerTerminalReleaseOnce(args).finally(() => { + if (activeByResource?.get(args.resource.id) === release) { + activeByResource.delete(args.resource.id) + } + }) + activeByResource.set(args.resource.id, release) + return release +} + +async function completeWorkerTerminalReleaseOnce( + args: WorkerTerminalReleaseArgs +): Promise { + const { runtime, db, dispatchId, resource } = args + const worker = db.getWorkerDispatch(dispatchId) + if (!worker || worker.agent_terminal_handle !== resource.terminal_handle) { + const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') + return { + dispatchId, + state: 'retained', + reason: 'identity_unproven', + processAction: 'none', + archive: archiveSummary(retained) + } + } + const observation = await inspectWorkerTerminal(runtime, db, dispatchId) + if (observation.status === 'identity_changed') { + const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') + return { + dispatchId, + state: 'retained', + reason: 'identity_unproven', + processAction: 'none', + archive: archiveSummary(retained) + } + } + if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, resource)) { + const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') + return { + dispatchId, + state: 'retained', + reason: 'identity_unproven', + processAction: 'none', + archive: archiveSummary(retained) + } + } + if (observation.status === 'missing' || observation.status === 'unattached') { + if (args.mode === 'recovery') { + // Inventory may still be incomplete during startup/reconnect discovery; defer. + return { + dispatchId, + state: 'release_pending', + processAction: 'none', + archive: archiveSummary(resource), + recovery: + 'The recorded terminal has not been rediscovered yet; recovery will retry after the next terminal inventory.' + } + } + // Why: the handle resolves nowhere, but the PTY could have been re-homed after a restart — + // claiming released would hide a live process; only an exact observation may settle it. + const unknown = db.markWorkerTerminalReleaseUnknown( + resource.id, + 'The recorded terminal no longer resolves; whether its process is gone cannot be proven.' + ) + return { + dispatchId, + state: 'release_unknown', + processAction: 'none', + archive: archiveSummary(unknown), + lastError: unknown.release_error ?? undefined, + recovery: `Inspect with: orca orchestration worker-show --dispatch ${dispatchId} --json — then repeat worker-release with the same --retry-request. Never substitute a broad terminal close.` + } + } + + const archive = db.getWorkerTerminalArchive(dispatchId) + let archiveSource = resource.archive_source as 'transcript' | 'terminal' | null + let archiveStatus: WorkerTerminalArchiveStatus | null = resource.archive_status + let capturedArchive: { kind: 'transcript_pin' | 'terminal_tail'; content: string } | undefined + if (!archive) { + const captured = await captureWorkerOutputArchive({ + runtime, + dispatchId, + terminalHandle: resource.terminal_handle, + attachedAtMs: orchestrationTimestampToMs(worker.created_at) + }) + capturedArchive = { kind: captured.kind, content: JSON.stringify(captured.content) } + archiveSource = captured.kind === 'transcript_pin' ? 'transcript' : 'terminal' + archiveStatus = captured.status + } else { + const stored = summarizeStoredArchive(archive) + archiveSource ??= stored.source + archiveStatus ??= stored.status + } + const releasing = db.commitWorkerTerminalArchiveForRelease({ + dispatchId, + resourceId: resource.id, + ...capturedArchive, + archiveSource, + archiveStatus: archiveStatus === 'empty' ? 'empty' : 'captured' + }) + if (releasing.ownership_state !== 'owned' || releasing.release_state !== 'releasing') { + return { + dispatchId, + state: 'retained', + reason: retainedReason(releasing), + processAction: 'none', + archive: archiveSummary(releasing) + } + } + if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, releasing)) { + const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') + return { + dispatchId, + state: 'retained', + reason: 'identity_unproven', + processAction: 'none', + archive: archiveSummary(retained) + } + } + + try { + await runtime.closeTerminal(resource.terminal_handle) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + if (/disposed|not connected|unavailable/i.test(reason)) { + // Durable intent exists; the owning endpoint is temporarily unreachable. Recovery retries. + return { + dispatchId, + state: 'release_pending', + processAction: 'none', + archive: { source: archiveSource, status: archiveStatus }, + lastError: reason, + recovery: + 'The owning endpoint is temporarily unavailable; recovery will retry this release after reconnect without another coordinator decision.' + } + } + const unknown = db.markWorkerTerminalReleaseUnknown(resource.id, reason) + return { + dispatchId, + state: 'release_unknown', + processAction: 'none', + archive: { source: archiveSource, status: archiveStatus }, + lastError: unknown.release_error ?? reason, + recovery: `Inspect with: orca orchestration worker-show --dispatch ${dispatchId} --json — then repeat worker-release with the same --retry-request. Never substitute a broad terminal close.` + } + } + const released = db.settleWorkerTerminalRelease(resource.id) + runtime.notifyMessageArrived(`dispatch:${dispatchId}`, 'status') + return { + dispatchId, + state: 'released', + processAction: + observation.status === 'exited' ? 'closed_exited_terminal' : 'closed_agent_terminal', + archive: archiveSummary(released) + } +} + +function workerTerminalLeaseIsCurrent( + runtime: OrcaRuntimeService, + db: OrchestrationDb, + dispatchId: string, + resource: WorkerTerminalResourceRow +): boolean { + const worker = db.getWorkerDispatch(dispatchId) + const authority = runtime.getOrchestrationDispatchAuthority(resource.terminal_handle) + return Boolean( + worker?.agent_terminal_handle === resource.terminal_handle && + authority && + resource.host_scope === JSON.stringify(authority.hostScope) && + db.isDispatchProcessCurrent({ + dispatchId, + paneKey: runtime.getTerminalPaneKey(resource.terminal_handle), + processIncarnation: runtime.getTerminalProcessIncarnation(resource.terminal_handle) + }) && + !db.workerTerminalResourceHasIdentityConflict(resource.id) + ) +} + +function summarizeStoredArchive(archive: WorkerTerminalArchiveRow): { + source: 'transcript' | 'terminal' + status: Extract +} { + if (archive.kind === 'transcript_pin') { + return { source: 'transcript', status: 'captured' } + } + const content = JSON.parse(archive.content) as WorkerTerminalTailArchive + const empty = content.lines.every((line) => line.trim() === '') + return { source: 'terminal', status: empty ? 'empty' : 'captured' } +} + +function retainedReason(resource: WorkerTerminalResourceRow): WorkerTerminalRetainedReason { + if (resource.retained_reason) { + return resource.retained_reason as WorkerTerminalRetainedReason + } + if (resource.ownership_state === 'user_owned') { + return 'user_takeover' + } + return 'identity_unproven' +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts new file mode 100644 index 00000000000..175cac9edac --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts @@ -0,0 +1,314 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationDb } from '../../orchestration/db' +import { reconcileRequestedWorkerTerminalReleases } from '../../orchestration/worker-terminal-release-reconciliation' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' +import { ORCHESTRATION_METHODS } from './orchestration' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +describe('orchestration worker release recovery', () => { + let db: OrchestrationDb + let dbOpen = false + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string + + const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const workerPaneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + + function setup(): void { + db = new OrchestrationDb(':memory:') + dbOpen = true + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? coordinatorPaneKey : handle === 'term_worker' ? workerPaneKey : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === 'term_worker' ? 'runtime_test:term_worker:1' : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === 'term_worker' + ? ({ + terminalHandle: handle, + paneKey: workerPaneKey, + processIncarnation: 'runtime_test:term_worker:1', + hostScope: { kind: 'local', hostId: 'local' } + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockImplementation( + async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never + ) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ + id: 'repo::worktree' + } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + title: 'worker' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['worker output line 1', 'worker output line 2'], + truncated: false, + nextCursor: '2' + }) + vi.spyOn(runtime, 'closeTerminal').mockResolvedValue({ + handle: 'term_worker', + closed: true + } as never) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + activeRunId = db.createRun({ + objective: 'Release recovery test Run', + coordinatorHandle: 'term_coord', + coordinatorPaneKey + }).id + ctx = { runtime } + } + + afterEach(() => { + if (dbOpen) { + dbOpen = false + db.close() + } + vi.restoreAllMocks() + }) + + async function call(name: string, params: Record) { + const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + async function startWorker(): Promise<{ taskId: string; dispatchId: string }> { + const task = db.createTask({ spec: 'release recovery fixture task', runId: activeRunId }) + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'codex' + })) as { dispatchId: string; state: string } + expect(result.state).toBe('ready') + return { taskId: task.id, dispatchId: result.dispatchId } + } + + async function startSettledWorker( + outcome: 'succeeded' | 'failed' = 'succeeded' + ): Promise<{ taskId: string; dispatchId: string }> { + const worker = await startWorker() + expect( + db.settleWorkerReport({ + taskId: worker.taskId, + dispatchId: worker.dispatchId, + outcome, + result: `worker ${outcome}` + }).action + ).toBe('settled') + return worker + } + + it('finishes a requested release after restart-style interruption', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) + const interrupted = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + expect(interrupted.state).toBe('release_pending') + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('releasing') + + const result = await reconcileRequestedWorkerTerminalReleases(runtime) + expect(result).toMatchObject({ attempted: 1, released: 1 }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('released') + expect(runtime.closeTerminal).toHaveBeenCalledTimes(2) + }) + + it('defers instead of settling unknown while inventory is incomplete', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) + await call('orchestration.workerRelease', { dispatch: dispatchId }) + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + + const result = await reconcileRequestedWorkerTerminalReleases(runtime) + expect(result).toMatchObject({ attempted: 1, pending: 1, unknown: 0 }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('releasing') + }) + + it('never touches resources without requested releases', async () => { + setup() + await startSettledWorker() + const result = await reconcileRequestedWorkerTerminalReleases(runtime) + expect(result.attempted).toBe(0) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('coalesces overlapping reconciliation passes and closes each resource once', async () => { + setup() + const { dispatchId } = await startSettledWorker() + expect(db.requestWorkerTerminalRelease(dispatchId).disposition).toBe('requested') + const pendingClose = deferred>>() + vi.mocked(runtime.closeTerminal).mockReturnValue(pendingClose.promise) + + const first = reconcileRequestedWorkerTerminalReleases(runtime) + await vi.waitFor(() => expect(runtime.closeTerminal).toHaveBeenCalledTimes(1)) + const second = reconcileRequestedWorkerTerminalReleases(runtime) + expect(second).toBe(first) + pendingClose.resolve({ handle: 'term_worker', tabId: 'tab-worker', ptyKilled: true }) + + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ attempted: 1, released: 1 }), + expect.objectContaining({ attempted: 1, released: 1 }) + ]) + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + }) + + it('keeps live terminals bounded across 50 settled workers while controls survive', async () => { + setup() + for (let wave = 0; wave < 50; wave += 1) { + const worker = await startSettledWorker(wave % 2 === 0 ? 'succeeded' : 'failed') + const receipt = (await call('orchestration.workerRelease', { + dispatch: worker.dispatchId + })) as { state: string } + expect(receipt.state).toBe('released') + } + expect(runtime.closeTerminal).toHaveBeenCalledTimes(50) + + const control = await startWorker() + const listed = (await call('orchestration.workerList', { run: activeRunId })) as { + workers: { dispatchId: string; terminalState: string | null }[] + counts: Record + } + expect(listed.counts).toMatchObject({ released: 50, active: 1 }) + expect( + listed.workers.find((worker) => worker.dispatchId === control.dispatchId)?.terminalState + ).toBe('active') + expect(runtime.closeTerminal).toHaveBeenCalledTimes(50) + }) + + it('backfills legacy terminal resources as retained external evidence', () => { + setup() + const insertLegacy = (dispatchId: string, handle: string, paneKey: string | null): void => { + const task = db.createTask({ spec: `legacy ${dispatchId}`, runId: activeRunId }) + const raw = ( + db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } } + ).db + raw + .prepare( + `INSERT INTO dispatch_contexts (id, run_id, task_id, contract_version, assignee_pane_key, process_incarnation, status) + VALUES (?, ?, ?, 2, ?, ?, 'completed')` + ) + .run(dispatchId, activeRunId, task.id, paneKey, paneKey ? `inc:${dispatchId}` : null) + raw + .prepare( + `INSERT INTO worker_dispatches (dispatch_id, state, stage, agent_terminal_handle, residual_resources) + VALUES (?, 'succeeded', 'settled', ?, ?)` + ) + .run( + dispatchId, + handle, + JSON.stringify([{ kind: 'terminal', role: 'agent', action: 'created', id: handle }]) + ) + } + insertLegacy('ctx_unique', 'term_unique', 'tab_u:leaf_u') + insertLegacy('ctx_shared_a', 'term_shared', 'tab_s:leaf_s') + insertLegacy('ctx_shared_b', 'term_shared', 'tab_s:leaf_s') + insertLegacy('ctx_no_identity', 'term_bare', null) + ;( + db as unknown as { backfillWorkerTerminalResources: () => void } + ).backfillWorkerTerminalResources() + + for (const ambiguous of ['ctx_unique', 'ctx_shared_a', 'ctx_shared_b', 'ctx_no_identity']) { + expect(db.getWorkerTerminalResourceByOwner(ambiguous)).toMatchObject({ + ownership_state: 'external', + release_state: 'retained', + retained_reason: 'legacy_ambiguous' + }) + } + }) + + it('backfills a legacy creator plus explicit reuser as ambiguous', () => { + setup() + const insertLegacy = (dispatchId: string, action: 'created' | 'reused'): void => { + const task = db.createTask({ spec: dispatchId, runId: activeRunId }) + const raw = ( + db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } } + ).db + raw + .prepare( + `INSERT INTO dispatch_contexts (id, run_id, task_id, contract_version, assignee_pane_key, process_incarnation, status) + VALUES (?, ?, ?, 2, ?, ?, ?)` + ) + .run( + dispatchId, + activeRunId, + task.id, + workerPaneKey, + 'runtime_test:term_worker:1', + action === 'created' ? 'completed' : 'dispatched' + ) + raw + .prepare( + `INSERT INTO worker_dispatches (dispatch_id, state, stage, agent_terminal_handle, residual_resources) + VALUES (?, ?, 'legacy', 'term_worker', ?)` + ) + .run( + dispatchId, + action === 'created' ? 'succeeded' : 'ready', + JSON.stringify([{ kind: 'terminal', role: 'agent', action, id: 'term_worker' }]) + ) + } + insertLegacy('ctx_creator', 'created') + insertLegacy('ctx_reuser', 'reused') + ;( + db as unknown as { backfillWorkerTerminalResources: () => void } + ).backfillWorkerTerminalResources() + + for (const dispatchId of ['ctx_creator', 'ctx_reuser']) { + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'external', + release_state: 'retained', + retained_reason: 'legacy_ambiguous' + }) + } + }) + + it('removes terminal authority and archived output on orchestration reset', async () => { + setup() + const { dispatchId } = await startSettledWorker() + await call('orchestration.workerRelease', { dispatch: dispatchId }) + expect(db.getWorkerTerminalArchive(dispatchId)).toBeDefined() + + db.resetTasks() + + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toBeUndefined() + expect(db.getWorkerTerminalArchive(dispatchId)).toBeUndefined() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts new file mode 100644 index 00000000000..25670dda1e7 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts @@ -0,0 +1,763 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_METHODS } from './orchestration' +import type { RpcContext } from '../core' +import { OrchestrationDb } from '../../orchestration/db' +import { OrcaRuntimeService } from '../../orca-runtime' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +describe('orchestration worker release', () => { + let db: OrchestrationDb + let dbOpen = false + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string + + const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const workerPaneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + + function setup(): void { + db = new OrchestrationDb(':memory:') + dbOpen = true + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' + ? coordinatorPaneKey + : handle === 'term_worker' || handle === 'term_reminted' + ? workerPaneKey + : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === 'term_worker' || handle === 'term_reminted' ? 'runtime_test:term_worker:1' : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === 'term_worker' || handle === 'term_reminted' + ? ({ + terminalHandle: handle, + paneKey: workerPaneKey, + processIncarnation: 'runtime_test:term_worker:1', + hostScope: { kind: 'local', hostId: 'local' } + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockImplementation( + async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never + ) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ + id: 'repo::worktree' + } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + title: 'worker' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['worker output line 1', 'worker output line 2'], + truncated: false, + nextCursor: '2' + }) + vi.spyOn(runtime, 'closeTerminal').mockResolvedValue({ + handle: 'term_worker', + closed: true + } as never) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + activeRunId = db.createRun({ + objective: 'Release test Run', + coordinatorHandle: 'term_coord', + coordinatorPaneKey + }).id + ctx = { runtime } + } + + afterEach(() => { + if (dbOpen) { + dbOpen = false + db.close() + } + vi.restoreAllMocks() + }) + + function findMethod(name: string) { + const method = ORCHESTRATION_METHODS.find((m) => m.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + return method + } + + async function call(name: string, params: Record) { + const method = findMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + async function startWorker(options: { terminal?: string } = {}): Promise<{ + taskId: string + dispatchId: string + }> { + const task = db.createTask({ spec: 'release fixture task', runId: activeRunId }) + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + ...(options.terminal ? { terminal: options.terminal } : { agent: 'codex' }) + })) as { dispatchId: string; state: string } + expect(result.state).toBe('ready') + return { taskId: task.id, dispatchId: result.dispatchId } + } + + function settle(taskId: string, dispatchId: string, outcome: 'succeeded' | 'failed'): void { + const settlement = db.settleWorkerReport({ + taskId, + dispatchId, + outcome, + result: `worker ${outcome}` + }) + expect(settlement.action).toBe('settled') + } + + async function startSettledWorker( + outcome: 'succeeded' | 'failed' = 'succeeded', + options: { terminal?: string } = {} + ): Promise<{ taskId: string; dispatchId: string }> { + const worker = await startWorker(options) + settle(worker.taskId, worker.dispatchId, outcome) + return worker + } + + it('creates an owned resource for a fresh worker terminal', async () => { + setup() + const { dispatchId } = await startWorker() + const resource = db.getWorkerTerminalResourceByOwner(dispatchId) + expect(resource).toMatchObject({ + ownership_state: 'owned', + release_state: 'not_requested', + terminal_handle: 'term_worker', + pane_key: workerPaneKey, + process_incarnation: 'runtime_test:term_worker:1' + }) + }) + + it('releases a succeeded worker: archives then closes exactly the agent terminal', async () => { + setup() + const { dispatchId } = await startSettledWorker('succeeded') + + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + archive: { source: string | null; status: string | null } | null + } + + expect(receipt).toMatchObject({ + state: 'released', + processAction: 'closed_agent_terminal', + archive: { source: 'terminal', status: 'captured' } + }) + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + const resource = db.getWorkerTerminalResourceByOwner(dispatchId) + expect(resource?.release_state).toBe('released') + expect(resource?.ownership_state).toBe('released') + // Outcome is untouched by release. + expect(db.getWorkerDispatch(dispatchId)?.state).toBe('succeeded') + }) + + it('releases a failed worker the same way', async () => { + setup() + const { dispatchId } = await startSettledWorker('failed') + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + expect(receipt.state).toBe('released') + expect(db.getWorkerDispatch(dispatchId)?.state).toBe('failed') + }) + + it('is idempotent: a duplicate release returns already_released without another close', async () => { + setup() + const { dispatchId } = await startSettledWorker() + await call('orchestration.workerRelease', { dispatch: dispatchId }) + const second = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + expect(second).toMatchObject({ state: 'already_released', processAction: 'none' }) + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + }) + + it('rejects an active worker without recording release intent', async () => { + setup() + const { dispatchId } = await startWorker() + await expect(call('orchestration.workerRelease', { dispatch: dispatchId })).rejects.toThrow( + /only a succeeded or failed worker can release/ + ) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('not_requested') + }) + + it('retains an explicitly reused external terminal without closing it', async () => { + setup() + const { dispatchId } = await startSettledWorker('succeeded', { terminal: 'term_worker' }) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + reason?: string + } + expect(receipt).toMatchObject({ state: 'retained', reason: 'external_terminal' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('retains a user-taken-over terminal durably', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const changed = (await call('orchestration.workerTerminalUserInput', { + paneKey: workerPaneKey + })) as { changed: number } + expect(changed.changed).toBe(1) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + reason?: string + } + expect(receipt).toMatchObject({ state: 'retained', reason: 'user_takeover' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe('user_owned') + }) + + it('lets user takeover cancel a release while output capture is pending', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const pendingRead = deferred>>() + vi.mocked(runtime.readTerminal).mockReturnValue(pendingRead.promise) + + const release = call('orchestration.workerRelease', { dispatch: dispatchId }) + await vi.waitFor(() => expect(runtime.readTerminal).toHaveBeenCalledTimes(1)) + const changed = (await call('orchestration.workerTerminalUserInput', { + paneKey: workerPaneKey + })) as { changed: number } + expect(changed.changed).toBe(1) + pendingRead.resolve({ + handle: 'term_worker', + status: 'running', + tail: ['captured before takeover'], + truncated: false, + nextCursor: '1' + }) + + await expect(release).resolves.toMatchObject({ state: 'retained', reason: 'user_takeover' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalArchive(dispatchId)).toBeUndefined() + }) + + it('lets an explicit retain cancel a release while output capture is pending', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const pendingRead = deferred>>() + vi.mocked(runtime.readTerminal).mockReturnValue(pendingRead.promise) + + const release = call('orchestration.workerRelease', { dispatch: dispatchId }) + await vi.waitFor(() => expect(runtime.readTerminal).toHaveBeenCalledTimes(1)) + await expect( + call('orchestration.workerRetain', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'retained', reason: 'user_requested' }) + pendingRead.resolve({ + handle: 'term_worker', + status: 'running', + tail: ['captured before retention'], + truncated: false, + nextCursor: '1' + }) + + await expect(release).resolves.toMatchObject({ state: 'retained', reason: 'user_requested' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalArchive(dispatchId)).toBeUndefined() + }) + + it('does not claim retention succeeded after terminal close was committed', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const pendingClose = deferred>>() + vi.mocked(runtime.closeTerminal).mockReturnValue(pendingClose.promise) + + const release = call('orchestration.workerRelease', { dispatch: dispatchId }) + await vi.waitFor(() => expect(runtime.closeTerminal).toHaveBeenCalledTimes(1)) + await expect( + call('orchestration.workerRetain', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_pending' }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('releasing') + pendingClose.resolve({ handle: 'term_worker', tabId: 'tab-worker', ptyKilled: true }) + + await expect(release).resolves.toMatchObject({ state: 'released' }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('released') + }) + + it('never marks takeover for panes without an owned resource', async () => { + setup() + const changed = (await call('orchestration.workerTerminalUserInput', { + paneKey: 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc' + })) as { changed: number } + expect(changed.changed).toBe(0) + }) + + it('preserves takeover across a reminted tab key for the same pane leaf', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const changed = (await call('orchestration.workerTerminalUserInput', { + paneKey: 'tab_reminted:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + })) as { changed: number } + + expect(changed.changed).toBe(1) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe('user_owned') + }) + + it('retains when the exact process identity changed instead of closing', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.getTerminalProcessIncarnation).mockImplementation((handle) => + handle === 'term_worker' ? 'runtime_test:term_worker:2' : null + ) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + reason?: string + } + expect(receipt).toMatchObject({ state: 'retained', reason: 'identity_unproven' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('retains when the terminal host scope changed instead of closing', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.getOrchestrationDispatchAuthority).mockReturnValue({ + terminalHandle: 'term_worker', + paneKey: workerPaneKey, + processIncarnation: 'runtime_test:term_worker:1', + hostScope: { kind: 'ssh', targetId: 'replacement-host' } + } as never) + + await expect( + call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'retained', reason: 'identity_unproven' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('re-proves process identity after archive capture before closing', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const pendingRead = deferred>>() + vi.mocked(runtime.readTerminal).mockReturnValue(pendingRead.promise) + + const release = call('orchestration.workerRelease', { dispatch: dispatchId }) + await vi.waitFor(() => expect(runtime.readTerminal).toHaveBeenCalledTimes(1)) + vi.mocked(runtime.getTerminalProcessIncarnation).mockImplementation((handle) => + handle === 'term_worker' ? 'runtime_test:term_worker:2' : null + ) + pendingRead.resolve({ + handle: 'term_worker', + status: 'running', + tail: ['output from the old process'], + truncated: false, + nextCursor: '1' + }) + + await expect(release).resolves.toMatchObject({ + state: 'retained', + reason: 'identity_unproven' + }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('returns release_unknown when the terminal no longer resolves, then completes a retry', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + recovery?: string + } + expect(receipt.state).toBe('release_unknown') + expect(receipt.recovery).toContain('worker-show') + expect(runtime.closeTerminal).not.toHaveBeenCalled() + + vi.mocked(runtime.showTerminal).mockImplementation( + async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never + ) + const retry = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + expect(retry.state).toBe('released') + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + }) + + it('retains the live terminal when output capture fails', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.readTerminal).mockRejectedValue(new Error('read exploded')) + await expect(call('orchestration.workerRelease', { dispatch: dispatchId })).rejects.toThrow( + /Output could not be preserved/ + ) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + // Durable intent survives for recovery. + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('requested') + }) + + it('marks release_unknown when the close itself fails', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.closeTerminal).mockRejectedValue(new Error('close exploded')) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + lastError?: string + } + expect(receipt.state).toBe('release_unknown') + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('unknown') + }) + + it('records an explicitly empty archive for an already-exited worker process', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockImplementation( + async (handle) => ({ handle, worktreeId: 'repo::worktree', connected: false }) as never + ) + vi.mocked(runtime.readTerminal).mockResolvedValue({ + handle: 'term_worker', + status: 'exited', + tail: [], + truncated: false, + nextCursor: null + }) + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + archive: { status: string | null } | null + } + expect(receipt).toMatchObject({ + state: 'released', + processAction: 'closed_exited_terminal', + archive: { status: 'empty' } + }) + }) + + it('keeps a bounded tail when one terminal line exceeds the archive budget', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const suffix = 'meaningful-tail' + vi.mocked(runtime.readTerminal).mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: [`${'x'.repeat(300_000)}${suffix}`], + truncated: false, + nextCursor: '1' + }) + + const release = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + archive: { status: string | null } | null + } + const read = (await call('orchestration.workerRead', { dispatch: dispatchId })) as { + terminal: { tail: string[]; truncated: boolean } + warnings: string[] + } + + expect(release.archive?.status).toBe('captured') + expect(read.terminal.tail).toHaveLength(1) + expect(read.terminal.tail[0]).toMatch(new RegExp(`${suffix}$`)) + expect(read.terminal.truncated).toBe(true) + expect(read.warnings).not.toContain( + 'The live terminal buffer was empty at release; structured transcript output was unavailable.' + ) + }) + + it('serves the frozen redacted archive through worker-read after release, with cursors', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.readTerminal).mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['first line', `capability dcap_${'a'.repeat(24)} leaked`, 'last line'], + truncated: false, + nextCursor: '3' + }) + await call('orchestration.workerRelease', { dispatch: dispatchId }) + vi.mocked(runtime.readTerminal).mockClear() + + const page1 = (await call('orchestration.workerRead', { + dispatch: dispatchId, + limit: 2 + })) as { archived?: boolean; terminal: { tail: string[] }; cursor: string | null } + expect(page1.archived).toBe(true) + expect(page1.terminal.tail).toEqual([ + 'first line', + 'capability [dispatch capability redacted] leaked' + ]) + expect(page1.cursor).not.toBeNull() + + const page2 = (await call('orchestration.workerRead', { + dispatch: dispatchId, + cursor: page1.cursor as string + })) as { terminal: { tail: string[] }; cursor: string | null } + expect(page2.terminal.tail).toEqual(['last line']) + expect(page2.cursor).toBeNull() + // The live terminal is never consulted after release. + expect(runtime.readTerminal).not.toHaveBeenCalled() + }) + + it('reads an immutable transcript snapshot after the provider file disappears', async () => { + setup() + const directory = await mkdtemp(join(tmpdir(), 'orca-worker-release-snapshot-')) + const transcriptPath = join(directory, 'rollout.jsonl') + try { + await writeFile( + transcriptPath, + `${JSON.stringify({ + timestamp: '2026-08-03T12:00:00.000Z', + type: 'event_msg', + payload: { id: 'snapshot-message', type: 'agent_message', message: 'frozen output' } + })}\n` + ) + vi.mocked(runtime.getExactWorkerProviderSession).mockReturnValue({ + agent: 'codex', + processIncarnation: 'runtime_test:term_worker:1', + providerSession: { + key: 'codex:snapshot-session', + id: 'snapshot-session', + transcriptPath + } + } as never) + const { dispatchId } = await startSettledWorker() + await call('orchestration.workerRelease', { dispatch: dispatchId }) + await rm(transcriptPath) + + await expect( + call('orchestration.workerRead', { dispatch: dispatchId }) + ).resolves.toMatchObject({ + archived: true, + source: 'transcript', + transcript: { + messages: [{ id: 'snapshot-message', blocks: [{ type: 'text', text: 'frozen output' }] }] + } + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a legacy live-terminal cursor after output moves to the archive', async () => { + setup() + const { dispatchId } = await startSettledWorker() + await call('orchestration.workerRelease', { dispatch: dispatchId }) + + await expect( + call('orchestration.workerRead', { dispatch: dispatchId, cursor: 1 }) + ).rejects.toThrow(/source changed/i) + }) + + it('recovers archive metadata when a prior attempt committed only the archive row', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const requested = db.requestWorkerTerminalRelease(dispatchId) + expect(requested.disposition).toBe('requested') + if (requested.disposition !== 'requested') { + throw new Error('release request was not recorded') + } + db.storeWorkerTerminalArchive({ + dispatchId, + resourceId: requested.resource.id, + kind: 'terminal_tail', + content: JSON.stringify({ + lines: ['archive survived the interrupted attempt'], + truncated: false, + terminalStatus: 'running', + warnings: [] + }) + }) + + const release = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + archive: { source: string | null; status: string | null } | null + } + + expect(release.archive).toEqual({ source: 'terminal', status: 'captured' }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + archive_source: 'terminal', + archive_status: 'captured' + }) + }) + + it('transfers ownership on exact reuse and fences release through the old Dispatch', async () => { + setup() + const first = await startSettledWorker('succeeded') + const originalResource = db.getWorkerTerminalResourceByOwner(first.dispatchId) + expect(originalResource?.ownership_state).toBe('owned') + + const second = await startWorker({ terminal: 'term_reminted' }) + const transferred = db.getWorkerTerminalResourceByOwner(second.dispatchId) + expect(transferred?.id).toBe(originalResource?.id) + expect(transferred?.terminal_handle).toBe('term_reminted') + expect(db.getWorkerTerminalResourceByOwner(first.dispatchId)).toBeUndefined() + + const oldRelease = (await call('orchestration.workerRelease', { + dispatch: first.dispatchId + })) as { state: string; reason?: string } + expect(oldRelease).toMatchObject({ state: 'retained', reason: 'ownership_transferred' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + + settle(second.taskId, second.dispatchId, 'succeeded') + const newRelease = (await call('orchestration.workerRelease', { + dispatch: second.dispatchId + })) as { state: string } + expect(newRelease.state).toBe('released') + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') + }) + + it('rejects exact reuse after release intent instead of closing the new worker', async () => { + setup() + const first = await startSettledWorker('succeeded') + expect(db.requestWorkerTerminalRelease(first.dispatchId).disposition).toBe('requested') + const nextTask = db.createTask({ spec: 'racing reuse', runId: activeRunId }) + + const attempted = (await call('orchestration.workerStart', { + task: nextTask.id, + from: 'term_coord', + terminal: 'term_worker' + })) as { state: string; lastError?: string } + + expect(attempted).toMatchObject({ state: 'failed' }) + expect(attempted.lastError).toMatch(/release.*progress/i) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + await expect( + call('orchestration.workerRelease', { dispatch: first.dispatchId }) + ).resolves.toMatchObject({ state: 'released' }) + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + }) + + it('retains when persisted state has another resource for the exact terminal identity', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const raw = ( + db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } } + ).db + raw + .prepare( + `INSERT INTO worker_terminal_resources ( + id, origin_dispatch_id, owner_dispatch_id, terminal_handle, pane_key, + process_incarnation, host_scope, ownership_state, release_state, retained_reason + ) VALUES ( + 'wtr_conflict', 'ctx_conflict', 'ctx_conflict', 'term_reminted', ?, ?, ?, + 'external', 'retained', 'legacy_ambiguous' + )` + ) + .run( + workerPaneKey, + 'runtime_test:term_worker:1', + JSON.stringify({ kind: 'local', hostId: 'local' }) + ) + + await expect( + call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'retained', reason: 'identity_unproven' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('worker-retain records a durable user exception that release can later replace', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const retained = (await call('orchestration.workerRetain', { dispatch: dispatchId })) as { + state: string + reason?: string + } + expect(retained).toMatchObject({ state: 'retained', reason: 'user_requested' }) + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('retained') + + const release = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + expect(release.state).toBe('released') + }) + + it('worker-list separates terminal accounting from Task outcome', async () => { + setup() + const active = await startWorker() + const perWorkerLookup = vi.spyOn(db, 'getWorkerTerminalResourceByOwner') + perWorkerLookup.mockClear() + const result1 = (await call('orchestration.workerList', { run: activeRunId })) as { + workers: { dispatchId: string; terminalState: string | null; workerState: string }[] + counts: Record + } + expect(result1.workers).toHaveLength(1) + expect(result1.workers[0]).toMatchObject({ + dispatchId: active.dispatchId, + terminalState: 'active', + workerState: 'ready' + }) + expect(perWorkerLookup).not.toHaveBeenCalled() + + settle(active.taskId, active.dispatchId, 'succeeded') + const result2 = (await call('orchestration.workerList', { + run: activeRunId, + terminalState: 'reclaimable' + })) as { workers: { dispatchId: string }[]; counts: Record } + expect(result2.workers.map((worker) => worker.dispatchId)).toEqual([active.dispatchId]) + expect(result2.counts).toMatchObject({ reclaimable: 1 }) + + await call('orchestration.workerRelease', { dispatch: active.dispatchId }) + const result3 = (await call('orchestration.workerList', { run: activeRunId })) as { + workers: { terminalState: string | null; workerState: string }[] + } + expect(result3.workers[0]).toMatchObject({ + terminalState: 'released', + workerState: 'succeeded' + }) + }) + + it('reports abandoned workers as retained instead of reclaimable', async () => { + setup() + const { dispatchId } = await startWorker() + await call('orchestration.workerAbandon', { dispatch: dispatchId }) + + const listed = (await call('orchestration.workerList', { run: activeRunId })) as { + workers: { dispatchId: string; terminalState: string | null }[] + } + + expect(listed.workers).toContainEqual( + expect.objectContaining({ dispatchId, terminalState: 'retained' }) + ) + await expect(call('orchestration.workerRelease', { dispatch: dispatchId })).rejects.toThrow( + /only a succeeded or failed worker can release/ + ) + }) + + it('worker-show exposes the terminal resource', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const shown = (await call('orchestration.workerShow', { dispatch: dispatchId })) as { + terminalResource: { ownershipState: string; releaseState: string } | null + } + expect(shown.terminalResource).toMatchObject({ + ownershipState: 'owned', + releaseState: 'not_requested' + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release.ts b/src/main/runtime/rpc/methods/orchestration-worker-release.ts new file mode 100644 index 00000000000..f01cd72de47 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release.ts @@ -0,0 +1,161 @@ +import { z } from 'zod' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { WorkerTerminalListState } from '../../orchestration/worker-terminal-ownership' +import { defineMethod, type RpcMethod } from '../core' +import { requiredString } from '../schemas' +import { + archiveSummary, + completeWorkerTerminalRelease, + exposeWorkerTerminalResource, + type WorkerReleaseReceipt +} from './orchestration-worker-release-completion' + +const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) + +const WORKER_TERMINAL_LIST_STATES = [ + 'active', + 'reclaimable', + 'retained', + 'release_pending', + 'release_unknown', + 'released' +] as const + +const WorkerListParams = z.object({ + run: z.string().min(1).optional(), + terminalState: z.enum(WORKER_TERMINAL_LIST_STATES).optional() +}) + +export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.workerRelease', + params: WorkerDispatchParams, + handler: async (params, { runtime }): Promise => { + const db = runtime.getOrchestrationDb() + if (db.getFederatedDispatch(params.dispatch)) { + // Fail closed: the worker server owns that terminal; a home-side close would be a guess. + return { + dispatchId: params.dispatch, + state: 'retained', + reason: 'federation_unsupported', + processAction: 'none', + archive: null, + recovery: + 'Connected-server workers do not support release yet; inspect the worker server directly.' + } + } + const requested = db.requestWorkerTerminalRelease(params.dispatch) + if (requested.disposition === 'already_released') { + return { + dispatchId: params.dispatch, + state: 'already_released', + processAction: 'none', + archive: archiveSummary(requested.resource) + } + } + if (requested.disposition === 'retained') { + return { + dispatchId: params.dispatch, + state: 'retained', + reason: requested.reason, + processAction: 'none', + archive: archiveSummary(requested.resource) + } + } + return completeWorkerTerminalRelease({ + runtime, + db, + dispatchId: params.dispatch, + resource: requested.resource + }) + } + }), + defineMethod({ + name: 'orchestration.workerRetain', + params: WorkerDispatchParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (!db.getWorkerDispatch(params.dispatch)) { + throw new OrchestrationError( + 'dispatch_not_found', + `Worker Dispatch ${params.dispatch} was not found.` + ) + } + const retained = db.retainWorkerTerminalResource(params.dispatch) + if (retained.disposition === 'already_released') { + return { + dispatchId: params.dispatch, + state: 'already_released' as const, + processAction: 'none' as const, + archive: archiveSummary(retained.resource) + } + } + if (retained.disposition === 'no_owned_resource') { + return { + dispatchId: params.dispatch, + state: 'retained' as const, + reason: 'no_owned_resource' as const, + processAction: 'none' as const, + archive: null + } + } + if (retained.disposition === 'release_committed') { + const unknown = retained.resource.release_state === 'unknown' + return { + dispatchId: params.dispatch, + state: unknown ? ('release_unknown' as const) : ('release_pending' as const), + processAction: 'none' as const, + archive: archiveSummary(retained.resource), + ...(retained.resource.release_error + ? { lastError: retained.resource.release_error } + : {}), + recovery: + 'Terminal release was already committed and could not be changed to retained; inspect worker-show before taking further action.' + } + } + return { + dispatchId: params.dispatch, + state: 'retained' as const, + reason: 'user_requested' as const, + processAction: 'none' as const, + archive: archiveSummary(retained.resource) + } + } + }), + defineMethod({ + name: 'orchestration.workerList', + params: WorkerListParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const rows = db.listWorkerTerminalResources({ runId: params.run }) + const workers = rows + .filter((row) => !params.terminalState || row.terminalState === params.terminalState) + .map((row) => ({ + dispatchId: row.dispatchId, + taskId: row.taskId, + runId: row.runId, + workerState: row.workerState, + dispatchStatus: row.dispatchStatus, + agentTerminalHandle: row.agentTerminalHandle, + terminalState: row.terminalState, + resource: row.resource ? exposeWorkerTerminalResource(row.resource) : null + })) + const counts: Partial> = {} + for (const row of rows) { + if (row.terminalState) { + counts[row.terminalState] = (counts[row.terminalState] ?? 0) + 1 + } + } + return { workers, counts } + } + }), + defineMethod({ + name: 'orchestration.workerTerminalUserInput', + params: z.object({ paneKey: requiredString('Missing paneKey') }), + // Real user keystrokes durably relinquish orchestration ownership on the owning runtime, so + // restarts, SSH drops, remote viewing, and renderer remounts cannot erase the takeover. + handler: (params, { runtime }) => ({ + changed: runtime.getOrchestrationDb().markWorkerTerminalUserOwned(params.paneKey) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index ffacf571d1b..6ecf8613a9b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -47,7 +47,8 @@ export function requireWorkerAuthority(runtime: OrcaRuntimeService, terminalHand return { paneKey, processIncarnation, - ...(authority?.launchTokenHash ? { launchTokenHash: authority.launchTokenHash } : {}) + ...(authority?.launchTokenHash ? { launchTokenHash: authority.launchTokenHash } : {}), + ...(authority?.hostScope ? { hostScope: JSON.stringify(authority.hostScope) } : {}) } } diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index 894be342f81..dc659773a8a 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -243,7 +243,8 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ ...terminalAuthority, worktreeId: resolvedWorktree.id, effects, - setupState: setupReceipt.state + setupState: setupReceipt.state, + terminalOwnership: params.terminal ? 'external' : 'created' }) failedStage = 'dispatch_input' diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index ad663269a37..e2aaf31b6eb 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -108,7 +108,11 @@ describe('orchestration RPC methods', () => { it('registers all expected methods', () => { const registry = buildRegistry(ORCHESTRATION_METHODS) - expect(registry.size).toBe(34) + expect(registry.size).toBe(38) + expect(registry.has('orchestration.workerRelease')).toBe(true) + expect(registry.has('orchestration.workerRetain')).toBe(true) + expect(registry.has('orchestration.workerList')).toBe(true) + expect(registry.has('orchestration.workerTerminalUserInput')).toBe(true) expect(registry.has('orchestration.runCreate')).toBe(true) expect(registry.has('orchestration.runUse')).toBe(true) expect(registry.has('orchestration.runCurrent')).toBe(true) diff --git a/src/main/runtime/rpc/orchestration-mutation-executor.ts b/src/main/runtime/rpc/orchestration-mutation-executor.ts index 66918510b56..2334cd709eb 100644 --- a/src/main/runtime/rpc/orchestration-mutation-executor.ts +++ b/src/main/runtime/rpc/orchestration-mutation-executor.ts @@ -54,6 +54,8 @@ export class OrchestrationMutationExecutor { return { disposition: row.state, row } })() : db.beginMutationReceipt(identity) + const resumedPendingMutation = + begun.disposition === 'pending' && request.method === 'orchestration.workerRelease' if (begun.disposition === 'completed') { const active = this.inFlight.get(key) @@ -67,33 +69,35 @@ export class OrchestrationMutationExecutor { if (active) { return attachMutationReceipt(await active, requestId, true) } - const recovery = getPendingWorkerStartRecovery(request.method, begun.row.receipt) - throw new OrchestrationError( - 'operation_unknown', - recovery - ? `Worker start ${requestId} was accepted as Dispatch ${recovery.dispatchId} before restart. Inspect that Dispatch; do not start another worker.` - : `Mutation ${requestId} may have been accepted before restart. Retry inspection or recovery with the same request ID.`, - recovery - ? { - requestId, - dispatchId: recovery.dispatchId, - recoveryCommand: `orca orchestration worker-show --dispatch ${recovery.dispatchId} --json` - } - : { requestId } - ) + if (request.method !== 'orchestration.workerRelease') { + const recovery = getPendingWorkerStartRecovery(request.method, begun.row.receipt) + throw new OrchestrationError( + 'operation_unknown', + recovery + ? `Worker start ${requestId} was accepted as Dispatch ${recovery.dispatchId} before restart. Inspect that Dispatch; do not start another worker.` + : `Mutation ${requestId} may have been accepted before restart. Retry inspection or recovery with the same request ID.`, + recovery + ? { + requestId, + dispatchId: recovery.dispatchId, + recoveryCommand: `orca orchestration worker-show --dispatch ${recovery.dispatchId} --json` + } + : { requestId } + ) + } } const recordReceipt = (result: unknown): void => { db.completeMutationReceipt({ ...identity, - receipt: JSON.stringify(attachMutationReceipt(result, requestId, false)) + receipt: JSON.stringify(attachMutationReceipt(result, requestId, resumedPendingMutation)) }) } const active = Promise.resolve().then(() => invoke({ identity, recordReceipt })) this.inFlight.set(key, active) try { const result = await active - const receipted = attachMutationReceipt(result, requestId, false) + const receipted = attachMutationReceipt(result, requestId, resumedPendingMutation) db.completeMutationReceipt({ ...identity, receipt: JSON.stringify(receipted) }) return receipted } catch (error) { diff --git a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts index 8e02d9c8e24..4062dacf9a1 100644 --- a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts @@ -196,6 +196,54 @@ describe('durable orchestration mutation ledger', () => { db.close() }) + it('resumes a pending idempotent worker release after restart', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + const params = { dispatch: 'ctx_release' } + const callerFingerprint = createHash('sha256').update('caller-token').digest('hex') + const payloadHash = createHash('sha256') + .update(JSON.stringify({ method: 'orchestration.workerRelease', params })) + .digest('hex') + db.beginMutationReceipt({ + callerFingerprint, + requestId: 'mutation_release', + method: 'orchestration.workerRelease', + payloadHash + }) + const effect = vi.fn().mockReturnValue({ state: 'release_pending' }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: [ + defineMethod({ + name: 'orchestration.workerRelease', + params: z.object({ dispatch: z.string() }), + handler: effect + }) + ] + }) + + const result = await dispatcher.dispatch({ + id: 'rpc_release_retry', + authToken: 'caller-token', + method: 'orchestration.workerRelease', + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'mutation_release' + }) + + expect(result).toMatchObject({ + ok: true, + result: { + state: 'release_pending', + mutation: { requestId: 'mutation_release', replayed: true } + } + }) + expect(effect).toHaveBeenCalledTimes(1) + expect(db.getMutationReceipt(callerFingerprint, 'mutation_release')?.state).toBe('completed') + db.close() + }) + it('returns the accepted Dispatch when worker-start was interrupted by restart', async () => { const db = new OrchestrationDb(':memory:') const runtime = new OrcaRuntimeService() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index e4b03fcb5ff..b5172cd89e3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -5,6 +5,7 @@ import type { IBuffer, IDisposable } from '@xterm/xterm' import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' import { installTerminalImeCompositionRoute } from './terminal-ime-composition-route' import { detectAgentStatusFromTitle, agentTypeToIconAgent, isClaudeAgent } from '@/lib/agent-status' +import { reportWorkerTerminalUserInput } from '@/lib/worker-terminal-takeover-report' import { resolvePaneTitleDecision } from './terminal-title-evidence' import { blocksCodexPaneInput } from '../codex-restart-notice-state' import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' @@ -3688,9 +3689,14 @@ export function connectPanePty( // "input after done" forever. The core user-input signal fires only for real // input, so hibernation activity records from it; onData recording remains // solely as the fallback when the internal API is unavailable. + const recordRealUserTerminalInput = (): void => { + recordTerminalInputForHibernation() + // Takeover must never fire from the onData fallback below: it mixes in auto-replies. + reportWorkerTerminalUserInput(cacheKey, runtimeEnvironmentId) + } const userInputActivityDisposable = subscribeToTerminalUserInput( pane.terminal, - recordTerminalInputForHibernation + recordRealUserTerminalInput ) const recordTerminalInputForHibernationFallback = (): void => { if (userInputActivityDisposable === null) { diff --git a/src/renderer/src/lib/worker-terminal-takeover-report.test.ts b/src/renderer/src/lib/worker-terminal-takeover-report.test.ts new file mode 100644 index 00000000000..94509465433 --- /dev/null +++ b/src/renderer/src/lib/worker-terminal-takeover-report.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { reportWorkerTerminalUserInput } from './worker-terminal-takeover-report' + +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) + +describe('reportWorkerTerminalUserInput', () => { + beforeEach(() => { + vi.mocked(callRuntimeRpc).mockReset().mockResolvedValue({ changed: 1 }) + }) + + it('throttles per runtime target so a rebound pane reports to its new owner', () => { + reportWorkerTerminalUserInput('pane-runtime-rebind', null) + reportWorkerTerminalUserInput('pane-runtime-rebind', null) + reportWorkerTerminalUserInput('pane-runtime-rebind', 'runtime-remote') + reportWorkerTerminalUserInput('pane-runtime-rebind', 'runtime-remote') + + expect(callRuntimeRpc).toHaveBeenCalledTimes(2) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 1, + { kind: 'local' }, + 'orchestration.workerTerminalUserInput', + { paneKey: 'pane-runtime-rebind' }, + { suppressFeatureInteraction: true, reuseRecentCompatibilityFailure: true } + ) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 2, + { kind: 'environment', environmentId: 'runtime-remote' }, + 'orchestration.workerTerminalUserInput', + { paneKey: 'pane-runtime-rebind' }, + { suppressFeatureInteraction: true, reuseRecentCompatibilityFailure: true } + ) + }) + + it('retries one transient report failure without waiting for more user input', async () => { + vi.mocked(callRuntimeRpc).mockRejectedValueOnce(new Error('runtime reconnecting')) + + reportWorkerTerminalUserInput('pane-transient-failure', 'runtime-reconnecting') + await vi.waitFor(() => expect(callRuntimeRpc).toHaveBeenCalledTimes(2), { timeout: 1_000 }) + + expect(callRuntimeRpc).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/lib/worker-terminal-takeover-report.ts b/src/renderer/src/lib/worker-terminal-takeover-report.ts new file mode 100644 index 00000000000..4a761046194 --- /dev/null +++ b/src/renderer/src/lib/worker-terminal-takeover-report.ts @@ -0,0 +1,61 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' + +// Why: one report per pane per window is enough to flip an owned orchestration resource to +// user_owned, but a pane can host a LATER dispatch, so re-report on continued typing instead +// of latching forever. +const REPORT_INTERVAL_MS = 30_000 +const REPORT_GATE_PRUNE_SIZE = 256 +const REPORT_RETRY_DELAY_MS = 250 + +const lastReportByPaneKey = new Map() + +// Fires only from the real-user-input signal (never xterm auto-replies, programmatic prompt +// delivery, resize, or output) and lands on the terminal-owning runtime, where the takeover is +// recorded durably in the orchestration DB. +export function reportWorkerTerminalUserInput( + paneKey: string, + runtimeEnvironmentId: string | null +): void { + const now = Date.now() + const gateKey = JSON.stringify([runtimeEnvironmentId, paneKey]) + const last = lastReportByPaneKey.get(gateKey) + if (last !== undefined && now - last < REPORT_INTERVAL_MS) { + return + } + if (lastReportByPaneKey.size >= REPORT_GATE_PRUNE_SIZE) { + for (const [key, reportedAt] of lastReportByPaneKey) { + if (now - reportedAt >= REPORT_INTERVAL_MS) { + lastReportByPaneKey.delete(key) + } + } + } + lastReportByPaneKey.set(gateKey, now) + void sendTakeoverReport(paneKey, runtimeEnvironmentId).catch(() => { + if (lastReportByPaneKey.get(gateKey) === now) { + lastReportByPaneKey.delete(gateKey) + } + }) +} + +async function sendTakeoverReport( + paneKey: string, + runtimeEnvironmentId: string | null +): Promise { + const target = + runtimeEnvironmentId !== null + ? ({ kind: 'environment', environmentId: runtimeEnvironmentId } as const) + : ({ kind: 'local' } as const) + const report = () => + callRuntimeRpc( + target, + 'orchestration.workerTerminalUserInput', + { paneKey }, + { suppressFeatureInteraction: true, reuseRecentCompatibilityFailure: true } + ) + try { + await report() + } catch { + await new Promise((resolve) => setTimeout(resolve, REPORT_RETRY_DELAY_MS)) + await report() + } +} diff --git a/src/shared/orchestration-rpc-contract.ts b/src/shared/orchestration-rpc-contract.ts index de85ef8d8bd..f95fe2f45c4 100644 --- a/src/shared/orchestration-rpc-contract.ts +++ b/src/shared/orchestration-rpc-contract.ts @@ -26,6 +26,8 @@ const ORCHESTRATION_MUTATION_METHODS = new Set([ 'orchestration.workerStart', 'orchestration.workerStop', 'orchestration.workerAbandon', + 'orchestration.workerRelease', + 'orchestration.workerRetain', 'orchestration.ask', 'orchestration.gateCreate', 'orchestration.gateResolve', diff --git a/src/shared/orchestration-worker-output.ts b/src/shared/orchestration-worker-output.ts index 250e8ccded9..8e61d1d3b97 100644 --- a/src/shared/orchestration-worker-output.ts +++ b/src/shared/orchestration-worker-output.ts @@ -44,6 +44,8 @@ export type OrchestrationWorkerReadTranscriptResult = { } fallbackReason: null warnings: string[] + // The live PTY was released; output comes from the frozen archive source. + archived?: boolean } export type OrchestrationWorkerReadTerminalResult = { @@ -58,6 +60,8 @@ export type OrchestrationWorkerReadTerminalResult = { } fallbackReason: OrchestrationWorkerReadFallbackReason | null warnings: string[] + // The live PTY was released; output comes from the frozen archive source. + archived?: boolean } export type OrchestrationWorkerReadResult = From 0ea3c9c31f663a36a60c5aa956cf112307c72cc1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:20:27 -0700 Subject: [PATCH 004/101] fix(renderer): drive agent working spinner on the compositor (STA-3328) (#12359) * fix(renderer): drive agent working spinner on the compositor (STA-3328) The shared 12Hz JS clock wrote el.style.transform on every mounted spinner (41 live = ~490 main-thread style writes/s), keeping style perpetually dirty and delaying keystroke dispatch (measured typing inputDelay p90 363ms; 19ms with the writes stopped). Replace it with a steps(12) CSS transform animation so rotation runs on the compositor; one mount-time animation-delay write preserves the shared-epoch phase sync. * fix(renderer): lock agent spinner phases to document time * fix(renderer): tolerate missing spinner animation API --- src/renderer/src/assets/main.css | 20 +++ .../src/components/AgentStateDot.test.ts | 8 +- .../components/AgentWorkingSpinner.test.tsx | 112 ++++++++++++++ .../src/components/AgentWorkingSpinner.tsx | 41 ++++- .../sidebar/StatusIndicator.test.ts | 9 +- .../src/lib/agent-spinner-clock.test.ts | 141 ------------------ src/renderer/src/lib/agent-spinner-clock.ts | 128 ---------------- 7 files changed, 172 insertions(+), 287 deletions(-) create mode 100644 src/renderer/src/components/AgentWorkingSpinner.test.tsx delete mode 100644 src/renderer/src/lib/agent-spinner-clock.test.ts delete mode 100644 src/renderer/src/lib/agent-spinner-clock.ts diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 315dd34c67a..47d81257dde 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1455,6 +1455,26 @@ html.native-shell .app-layout { grid-template-rows: 1fr; } +/* Why: must stay a compositor-driven CSS animation — a JS clock writing + per-element transforms blocks the renderer input thread (STA-3328: 41 + spinners ⇒ ~490 style writes/s, keystroke p90 363ms). The component anchors + each animation's startTime once; steps(12) preserves the retired cadence. */ +@keyframes agent-spinner-rotate { + to { + transform: rotate(360deg); + } +} + +.agent-working-spinner { + animation: agent-spinner-rotate 1s steps(12, end) infinite; +} + +@media (prefers-reduced-motion: reduce) { + .agent-working-spinner { + animation: none; + } +} + /* Why: keyframes run when the content mounts already in its final state. */ @keyframes compact-agent-expansion-reveal { from { diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 72fbc6223a6..b721c3d8a01 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -22,15 +22,13 @@ describe('AgentStateDot', () => { expect(markup).toContain('border-yellow-500') expect(markup).toContain('border-t-transparent') - // Why: rotation comes from the shared agent-spinner clock (which also - // honors prefers-reduced-motion), not a per-element CSS animation that - // would keep the compositor awake. + // Why: rotation must come from the compositor-driven CSS animation, not a + // JS clock writing per-element styles on the input thread (STA-3328). + expect(markup).toContain('agent-working-spinner') expect(markup).toContain('data-agent-spinner') // Why: under reduced motion the top border is filled so the static ring // reads as a complete marker, not a broken partial spinner (#9515). expect(markup).toContain('motion-reduce:border-t-yellow-500') - expect(markup).not.toContain('animate-spin') - expect(markup).not.toContain('animation:spin') }) it('renders done as an emerald check icon', () => { diff --git a/src/renderer/src/components/AgentWorkingSpinner.test.tsx b/src/renderer/src/components/AgentWorkingSpinner.test.tsx new file mode 100644 index 00000000000..25c04f39783 --- /dev/null +++ b/src/renderer/src/components/AgentWorkingSpinner.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { AgentWorkingSpinner } from './AgentWorkingSpinner' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +describe('AgentWorkingSpinner', () => { + it('hooks the compositor-driven CSS animation, not a JS clock', () => { + const markup = renderToStaticMarkup(React.createElement(AgentWorkingSpinner)) + + expect(markup).toContain('agent-working-spinner') + expect(markup).toContain('data-agent-spinner') + expect(markup).toContain('border-yellow-500') + expect(markup).toContain('border-t-transparent') + expect(markup).toContain('motion-reduce:border-t-yellow-500') + }) + + it('renders when the Web Animations API is unavailable', async () => { + const originalGetAnimations = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'getAnimations' + ) + Object.defineProperty(HTMLElement.prototype, 'getAnimations', { + configurable: true, + value: undefined + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + try { + await act(async () => { + root.render() + }) + expect(container.querySelector('[data-agent-spinner]')).not.toBeNull() + } finally { + act(() => root.unmount()) + container.remove() + if (originalGetAnimations === undefined) { + Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations') + } else { + Object.defineProperty(HTMLElement.prototype, 'getAnimations', originalGetAnimations) + } + } + }) + + it('anchors mount and restart animations to the shared document epoch', async () => { + const animation = { + animationName: 'agent-spinner-rotate', + startTime: 321 + } as unknown as Animation + const getAnimations = vi.fn(() => [animation]) + const originalGetAnimations = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'getAnimations' + ) + Object.defineProperty(HTMLElement.prototype, 'getAnimations', { + configurable: true, + value: getAnimations + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + try { + await act(async () => { + root.render() + }) + expect(animation.startTime).toBe(0) + + animation.startTime = 654 + const restart = new Event('animationstart', { bubbles: true }) + Object.defineProperty(restart, 'animationName', { value: 'agent-spinner-rotate' }) + await act(async () => { + container.querySelector('[data-agent-spinner]')!.dispatchEvent(restart) + }) + expect(animation.startTime).toBe(0) + expect(getAnimations).toHaveBeenCalledTimes(2) + } finally { + act(() => root.unmount()) + container.remove() + if (originalGetAnimations === undefined) { + Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations') + } else { + Object.defineProperty(HTMLElement.prototype, 'getAnimations', originalGetAnimations) + } + } + }) + + // Why: the class only spins if main.css defines it — pin the wiring across + // both files so neither side can be renamed or dropped alone (STA-3328 + // regressed typing latency when rotation moved onto the input thread). + it('is backed by a steps(12) keyframe animation in main.css', () => { + const css = readFileSync(join(__dirname, '../assets/main.css'), 'utf8') + + const rule = css.match(/\.agent-working-spinner\s*\{[^}]*\}/)?.[0] + expect(rule).toBeDefined() + expect(rule).toContain('animation: agent-spinner-rotate 1s steps(12, end) infinite') + expect(css).toContain('@keyframes agent-spinner-rotate') + + const reducedMotionBlock = css.match( + /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.agent-working-spinner\s*\{[^}]*\}/ + )?.[0] + expect(reducedMotionBlock).toContain('animation: none') + }) +}) diff --git a/src/renderer/src/components/AgentWorkingSpinner.tsx b/src/renderer/src/components/AgentWorkingSpinner.tsx index 7e3d3302a7e..9b20a862b09 100644 --- a/src/renderer/src/components/AgentWorkingSpinner.tsx +++ b/src/renderer/src/components/AgentWorkingSpinner.tsx @@ -1,21 +1,46 @@ import React from 'react' import { cn } from '@/lib/utils' -import { agentSpinnerRef } from '@/lib/agent-spinner-clock' -// Why: the working-state ring must not carry its own infinite CSS animation — -// that keeps the compositor awake per element for the whole agent run. The -// shared agent-spinner clock rotates every mounted ring in phase and stops -// cleanly when nothing can be seen. Callers size it via className (size-2 etc.). +const SPINNER_ANIMATION_NAME = 'agent-spinner-rotate' + +// Why: CSS assigns per-element start times after refs run; anchoring the Web +// Animation timeline gives late mounts exact phase sync without recurring JS. +function syncSpinnerPhase(el: HTMLSpanElement | null): void { + if (el === null || typeof el.getAnimations !== 'function') { + return + } + + const animation = el + .getAnimations() + .find( + (candidate) => + 'animationName' in candidate && candidate.animationName === SPINNER_ANIMATION_NAME + ) + if (animation !== undefined) { + animation.startTime = 0 + } +} + +function handleSpinnerAnimationStart(event: React.AnimationEvent): void { + if (event.animationName === SPINNER_ANIMATION_NAME) { + syncSpinnerPhase(event.currentTarget) + } +} + +// Why: the working-state ring animates via CSS (.agent-working-spinner in +// main.css) so rotation runs on the compositor and never touches the input +// thread. Callers size it via className (size-2 etc.). export function AgentWorkingSpinner({ className }: { className?: string }): React.JSX.Element { return ( diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index 871687ccbe1..be3e5e2a4c1 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -17,19 +17,18 @@ function renderDotClassNames(status: Status): string[] { } describe('StatusIndicator', () => { - it('renders working as a clock-driven yellow spinner ring', () => { + it('renders working as a yellow spinner ring', () => { const markup = renderMarkup('working') expect(markup).toContain('border-yellow-500') expect(markup).toContain('border-t-transparent') - // Why: rotation comes from the shared agent-spinner clock, not a - // per-element CSS animation that would keep the compositor awake. + // Why: rotation must come from the compositor-driven CSS animation, not a + // JS clock writing per-element styles on the input thread (STA-3328). + expect(markup).toContain('agent-working-spinner') expect(markup).toContain('data-agent-spinner') // Why: under reduced motion the top border is filled so the static ring // reads as a complete marker, not a broken partial spinner (#9515). expect(markup).toContain('motion-reduce:border-t-yellow-500') - expect(markup).not.toContain('animate-spin') - expect(markup).not.toContain('animation:spin') }) it('renders permission as an amber question glyph', () => { diff --git a/src/renderer/src/lib/agent-spinner-clock.test.ts b/src/renderer/src/lib/agent-spinner-clock.test.ts deleted file mode 100644 index cdf496995e9..00000000000 --- a/src/renderer/src/lib/agent-spinner-clock.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - AGENT_SPINNER_TICK_MS, - registerAgentSpinnerElement, - resetAgentSpinnerClockForTesting -} from './agent-spinner-clock' -import { resetStaleDocumentVisibilityForTesting } from '@/components/terminal-pane/stale-document-visibility' - -type FakeElement = { style: { transform: string } } - -function makeElement(): HTMLElement { - return { style: { transform: '' } } as unknown as HTMLElement -} - -function stubDom(visibilityState: () => DocumentVisibilityState): { - fireVisibilityChange: () => void -} { - const documentListeners = new Map void>>() - const addListener = (event: string, listener: (event?: unknown) => void): void => { - if (!documentListeners.has(event)) { - documentListeners.set(event, new Set()) - } - documentListeners.get(event)!.add(listener) - } - vi.stubGlobal('document', { - get visibilityState() { - return visibilityState() - }, - addEventListener: vi.fn(addListener), - removeEventListener: vi.fn((event: string, listener: (event?: unknown) => void) => { - documentListeners.get(event)?.delete(listener) - }) - }) - vi.stubGlobal('window', { - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - matchMedia: vi.fn(() => ({ - matches: false, - addEventListener: vi.fn(), - removeEventListener: vi.fn() - })) - }) - return { - fireVisibilityChange: () => { - for (const listener of documentListeners.get('visibilitychange') ?? []) { - listener() - } - } - } -} - -describe('agent-spinner-clock', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - resetAgentSpinnerClockForTesting() - resetStaleDocumentVisibilityForTesting() - vi.useRealTimers() - vi.unstubAllGlobals() - }) - - it('rotates every registered element in phase from one shared timer', () => { - stubDom(() => 'visible') - const a = makeElement() - const unregisterA = registerAgentSpinnerElement(a) - - // Registration seeds the current dial position immediately. - expect((a as unknown as FakeElement).style.transform).toMatch(/^rotate\(\d+deg\)$/) - - vi.advanceTimersByTime(AGENT_SPINNER_TICK_MS * 3) - const b = makeElement() - const unregisterB = registerAgentSpinnerElement(b) - - // A late joiner starts at the shared dial position, not at zero. - expect((b as unknown as FakeElement).style.transform).toBe( - (a as unknown as FakeElement).style.transform - ) - - const before = (a as unknown as FakeElement).style.transform - vi.advanceTimersByTime(AGENT_SPINNER_TICK_MS) - expect((a as unknown as FakeElement).style.transform).not.toBe(before) - expect((b as unknown as FakeElement).style.transform).toBe( - (a as unknown as FakeElement).style.transform - ) - - unregisterA() - unregisterB() - }) - - it('stops ticking while the document is hidden and resumes on visibility', () => { - let visibility: DocumentVisibilityState = 'visible' - const { fireVisibilityChange } = stubDom(() => visibility) - const el = makeElement() - const unregister = registerAgentSpinnerElement(el) - - visibility = 'hidden' - fireVisibilityChange() - const frozen = (el as unknown as FakeElement).style.transform - vi.advanceTimersByTime(AGENT_SPINNER_TICK_MS * 10) - expect((el as unknown as FakeElement).style.transform).toBe(frozen) - - visibility = 'visible' - fireVisibilityChange() - // Resume advances immediately so the restored window visibly spins again. - expect((el as unknown as FakeElement).style.transform).not.toBe(frozen) - - unregister() - }) - - it('clears its timer when the last element unregisters', () => { - stubDom(() => 'visible') - const el = makeElement() - const unregister = registerAgentSpinnerElement(el) - unregister() - - const parked = (el as unknown as FakeElement).style.transform - vi.advanceTimersByTime(AGENT_SPINNER_TICK_MS * 5) - expect((el as unknown as FakeElement).style.transform).toBe(parked) - expect(vi.getTimerCount()).toBe(0) - }) - - it('does not tick when prefers-reduced-motion is set', () => { - stubDom(() => 'visible') - const windowStub = globalThis.window as unknown as { matchMedia: ReturnType } - windowStub.matchMedia = vi.fn(() => ({ - matches: true, - addEventListener: vi.fn(), - removeEventListener: vi.fn() - })) - - const el = makeElement() - const unregister = registerAgentSpinnerElement(el) - const initial = (el as unknown as FakeElement).style.transform - vi.advanceTimersByTime(AGENT_SPINNER_TICK_MS * 5) - expect((el as unknown as FakeElement).style.transform).toBe(initial) - - unregister() - }) -}) diff --git a/src/renderer/src/lib/agent-spinner-clock.ts b/src/renderer/src/lib/agent-spinner-clock.ts deleted file mode 100644 index 895d6a4f8c8..00000000000 --- a/src/renderer/src/lib/agent-spinner-clock.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Why: a per-element infinite CSS animation keeps Chromium's frame pipeline -// awake the whole time any agent spinner is visible (~23.5ms CPU/s measured -// live, nearly independent of cadence, scaling per element). Background -// throttling only helps hidden windows — a visible sidebar full of working -// agents pays continuously. One shared low-rate clock steps every registered -// spinner element's transform directly, so the renderer idles between ticks -// and costs the same whether one agent or fifty are working. -import { isWindowVisible } from './window-visibility-interval' -import { - isDocumentVisibilityProvenStale, - registerStaleDocumentVisibilityRecovery -} from '@/components/terminal-pane/stale-document-visibility' - -const SPIN_STEP_DEGREES = 30 -const SPIN_STEPS = 360 / SPIN_STEP_DEGREES -// Why: 12 steps/s at 30° matches the retired CSS animation exactly — slower -// cadences read as sluggish next to it. The win over CSS is structural (idle -// between ticks, one timer for N spinners), not the tick rate. -export const AGENT_SPINNER_TICK_MS = 1000 / 12 - -const elements = new Set() -let timer: ReturnType | null = null -let step = 0 -let teardownGlobalListeners: (() => void) | null = null - -function currentTransform(): string { - return `rotate(${step * SPIN_STEP_DEGREES}deg)` -} - -function tick(): void { - step = (step + 1) % SPIN_STEPS - const transform = currentTransform() - for (const el of elements) { - el.style.transform = transform - } -} - -function prefersReducedMotion(): boolean { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { - return false - } - return window.matchMedia('(prefers-reduced-motion: reduce)').matches -} - -function shouldRun(): boolean { - // Why: the stale-visibility latch proves user input arrived while the macOS - // occlusion tracker still claims hidden — keep spinning for the user we know - // is watching instead of freezing until the tracker recovers. - const visible = isWindowVisible() || isDocumentVisibilityProvenStale() - return elements.size > 0 && visible && !prefersReducedMotion() -} - -function reconcile(): void { - if (shouldRun()) { - if (timer === null) { - // Advance immediately so a just-restored window visibly resumes. - tick() - timer = setInterval(tick, AGENT_SPINNER_TICK_MS) - } - return - } - if (timer !== null) { - clearInterval(timer) - timer = null - } -} - -function installGlobalListeners(): void { - if ( - teardownGlobalListeners !== null || - typeof document === 'undefined' || - typeof document.addEventListener !== 'function' - ) { - return - } - const onSignal = (): void => { - reconcile() - } - document.addEventListener('visibilitychange', onSignal) - const reducedMotionQuery = - typeof window !== 'undefined' && typeof window.matchMedia === 'function' - ? window.matchMedia('(prefers-reduced-motion: reduce)') - : null - reducedMotionQuery?.addEventListener?.('change', onSignal) - const unregisterStaleRecovery = registerStaleDocumentVisibilityRecovery(onSignal) - teardownGlobalListeners = () => { - document.removeEventListener('visibilitychange', onSignal) - reducedMotionQuery?.removeEventListener?.('change', onSignal) - unregisterStaleRecovery() - } -} - -export function registerAgentSpinnerElement(el: HTMLElement): () => void { - elements.add(el) - // Join in phase with the shared dial so late-mounting spinners stay in sync. - el.style.transform = currentTransform() - installGlobalListeners() - reconcile() - return () => { - elements.delete(el) - reconcile() - if (elements.size === 0 && teardownGlobalListeners !== null) { - teardownGlobalListeners() - teardownGlobalListeners = null - } - } -} - -/** Stable React ref callback: attach to a spinner element to spin it. */ -export function agentSpinnerRef(el: HTMLElement | null): (() => void) | undefined { - if (el === null) { - return undefined - } - return registerAgentSpinnerElement(el) -} - -export function resetAgentSpinnerClockForTesting(): void { - if (timer !== null) { - clearInterval(timer) - timer = null - } - elements.clear() - if (teardownGlobalListeners !== null) { - teardownGlobalListeners() - teardownGlobalListeners = null - } - step = 0 -} From 9ec4907cfb16338b9862d5c691b016e70f689072 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:22:43 -0700 Subject: [PATCH 005/101] fix(agent-status): restore hydrated nonterminal statuses as unconfirmed (#12346) * fix(agent-status): restore hydrated nonterminal statuses as unconfirmed A hook transition that fires while Electron is down has no receiver and is discarded, so last-status.json can restore a stale 'working' as confirmed truth for up to the 7-day hydrate TTL. Stamp hydrated nonterminal rows with restoredUnconfirmed, carry it through both IPC paths, and treat such rows as never-fresh in the shared and renderer freshness gates so the sidebar, worktree.ps, and the raw snapshot all present the same degraded semantics. Terminal states restore as-is; any accepted live event clears the flag; the flag itself is never persisted. Interrupt/question inference refuses to fabricate transitions onto unconfirmed rows. * fix(agent-status): shed unconfirmed marker when the liveness sweep verifies done The restored-subagent reaper's reconciled entry spread carried restoredUnconfirmed onto a process-probe-verified 'done', making freshness gates suppress a legitimate completion. Keep the marker only while the reconciled state stays nonterminal. * fix(agent-status): let live evidence replace hydrated rows * fix(agent-status): keep restored rows degraded Sort accepted live evidence after hydrated rows even across wall-clock rollback. Let unconfirmed rows own their preserved pane titles without asserting live state, while retaining independently live sibling evidence. * fix(agent-status): suppress unmapped restored titles Treat a single runtime title as covered by the single restored hook row while layout identity is unavailable. Preserve ordinary age-stale fallback and mapped sibling-pane evidence. --- .../restored-subagent-liveness-sweep.test.ts | 7 +- src/main/agent-hooks/server.test.ts | 288 ++++++++++++++++++ src/main/agent-hooks/server.ts | 42 ++- src/main/index.ts | 2 + src/main/runtime/orca-runtime.test.ts | 56 ++++ src/main/runtime/orca-runtime.ts | 14 +- .../sidebar/smart-attention.test.ts | 79 ++++- .../src/components/sidebar/smart-attention.ts | 14 +- .../sidebar/useWorktreeAgentRows.test.ts | 21 ++ .../worktree-agent-activity-summary.test.ts | 29 ++ .../worktree-agent-activity-summary.ts | 9 +- .../terminal-tab-activity-status.test.ts | 33 ++ .../tab-bar/terminal-tab-activity-status.ts | 38 ++- src/renderer/src/hooks/useIpcEvents.ts | 10 +- src/renderer/src/lib/pane-agent-evidence.ts | 5 +- .../src/lib/running-agent-targets.test.ts | 30 ++ src/renderer/src/lib/running-agent-targets.ts | 4 +- .../src/runtime/web-session-tabs-sync.test.ts | 55 ++++ .../src/runtime/web-session-tabs-sync.ts | 11 +- .../src/store/slices/agent-status.test.ts | 32 ++ src/renderer/src/store/slices/agent-status.ts | 5 +- src/shared/agent-status-identity.ts | 1 + src/shared/agent-status-types.test.ts | 21 ++ src/shared/agent-status-types.ts | 16 +- src/shared/runtime-types.ts | 2 + 25 files changed, 786 insertions(+), 38 deletions(-) diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts index 9caf7966c83..9811773eb07 100644 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts @@ -119,6 +119,7 @@ describe('restored subagent liveness sweep', () => { try { expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] }) const previous = server.getStatusSnapshotForPane(PANE)[0] + expect(previous?.restoredUnconfirmed).toBe(true) const previousReceivedAt = previous?.receivedAt ?? 0 const reconciledAt = previousReceivedAt + 1 const now = vi.spyOn(Date, 'now').mockReturnValue(previousReceivedAt) @@ -130,6 +131,8 @@ describe('restored subagent liveness sweep', () => { receivedAt: reconciledAt, stateStartedAt: reconciledAt }) + // Why: the reconciled `done` is process-probe-verified, so it must shed the hydrated-unconfirmed marker. + expect(server.getStatusSnapshotForPane(PANE)[0]?.restoredUnconfirmed).toBeUndefined() now.mockRestore() } finally { server.stop() @@ -163,7 +166,9 @@ describe('restored subagent liveness sweep', () => { state: 'waiting', subagents: undefined, receivedAt: previous?.receivedAt, - stateStartedAt: previous?.stateStartedAt + stateStartedAt: previous?.stateStartedAt, + // Why: a still-nonterminal reconciled row remains unconfirmed — only hooks or a terminal fact confirm it. + restoredUnconfirmed: true }) } finally { server.stop() diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index b3ff89ab355..96bb121c7e7 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -7026,6 +7026,294 @@ describe('Last-status persistence', () => { } }) + it('restores hydrated nonterminal statuses as unconfirmed until a live event lands', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'may finish offline', agentType: 'claude' } + }, + 'conn-1' + ) + firstServer.ingestRemote( + { + paneKey: GOOD_PANE, + tabId: 'tab-good', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'finished before restart', agentType: 'claude' } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: PANE, restoredUnconfirmed: true }) + ) + const byPane = new Map(server.getStatusSnapshot().map((entry) => [entry.paneKey, entry])) + expect(byPane.get(PANE)).toMatchObject({ state: 'working', restoredUnconfirmed: true }) + // Why: `done` is a terminal fact — no later transition can have been missed, so it restores confirmed. + expect(byPane.get(GOOD_PANE)?.restoredUnconfirmed).toBeUndefined() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'confirmed live', agentType: 'codex' } + }, + 'conn-1' + ) + const confirmed = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) + expect(confirmed).toMatchObject({ + state: 'done', + prompt: 'confirmed live', + agentType: 'codex' + }) + expect(confirmed?.restoredUnconfirmed).toBeUndefined() + } finally { + server.stop() + } + }) + + it('lets an identical live OSC observation confirm a hydrated row', async () => { + const payload = { + state: 'working' as const, + prompt: 'still running', + agentType: 'claude' as const + } + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload + }) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const restored = server.getStatusSnapshot()[0] + if (!restored) { + throw new Error('expected hydrated status') + } + expect(restored?.restoredUnconfirmed).toBe(true) + vi.spyOn(Date, 'now').mockReturnValue(restored.receivedAt - 1_000) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload + }) + const confirmed = server.getStatusSnapshot()[0] + expect(confirmed?.restoredUnconfirmed).toBeUndefined() + expect(confirmed?.receivedAt).toBe(restored.receivedAt + 1) + } finally { + server.stop() + } + }) + + it('does not let a hydrated Claude permission suppress the first live working event', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PermissionRequest', + payload: { + state: 'waiting', + prompt: 'review command', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'dangerous command' + } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PreToolUse', + payload: { + state: 'working', + prompt: 'review command', + agentType: 'claude', + toolName: 'Read', + toolInput: 'package.json' + } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + toolName: 'Read' + }) + expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() + } finally { + server.stop() + } + }) + + it('does not inherit a stale tool id from hydrated Claude progress', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PreToolUse', + toolUseId: 'tool-old', + toolAgentType: 'main', + payload: { + state: 'working', + prompt: 'run tests', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PermissionRequest', + toolAgentType: 'main', + payload: { + state: 'waiting', + prompt: 'run tests', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PostToolUse', + toolUseId: 'tool-new', + toolAgentType: 'main', + payload: { + state: 'working', + prompt: 'run tests', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + } finally { + server.stop() + } + }) + + it('never persists the unconfirmed flag and re-stamps it on every hydrate', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'still running', agentType: 'claude' } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const secondServer = new AgentHookServer() + await secondServer.start({ env: 'production', userDataPath }) + secondServer.flushStatusPersistSync() + secondServer.stop() + expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('restoredUnconfirmed') + + const thirdServer = new AgentHookServer() + await thirdServer.start({ env: 'production', userDataPath }) + try { + expect(thirdServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'working', restoredUnconfirmed: true }) + ]) + } finally { + thirdServer.stop() + } + }) + + it('refuses interrupt inference on an unconfirmed hydrated row', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'codex' } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const baseline = server.getStatusSnapshot()[0] + expect(baseline).toMatchObject({ paneKey: PANE, restoredUnconfirmed: true }) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + // Why: synthesizing `done` onto a never-confirmed `working` would fabricate a transition from stale disk state. + expect(applied).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + expect(server.getStatusSnapshot()[0]?.interrupted).toBeUndefined() + } finally { + server.stop() + } + }) + it('keeps SSH status ordering monotonic across hydration and clock rollback', async () => { const now = 1_700_000_000_000 vi.spyOn(Date, 'now').mockReturnValue(now) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 0f99f2cae9d..71fa1a6a0fc 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -83,6 +83,8 @@ export type { AgentHookSource } type EnrichedAgentHookEventPayload = AgentHookEventPayload & { receivedAt: number stateStartedAt: number + /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ + restoredUnconfirmed?: true } type NormalizedLocalHook = { @@ -92,7 +94,7 @@ type NormalizedLocalHook = { type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, - 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' + 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' > & { launchTokenHash?: string } @@ -378,6 +380,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), + ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...entry.payload } } @@ -434,6 +437,9 @@ function shouldKeepClaudePermissionVisible( previous: EnrichedAgentHookEventPayload | undefined, next: AgentHookEventPayload ): boolean { + if (previous?.restoredUnconfirmed) { + return false + } if ( previous?.payload.agentType !== 'claude' || previous.payload.state !== 'waiting' || @@ -507,6 +513,7 @@ function shouldInheritClaudeToolUseIdForPermission( next: AgentHookEventPayload ): boolean { if ( + previous?.restoredUnconfirmed || previous?.payload.agentType !== 'claude' || previous.payload.state !== 'working' || previous.hookEventName !== 'PreToolUse' || @@ -728,6 +735,10 @@ export class AgentHookServer { if (existing.providerSessionOnly) { return false } + // Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } const payload = existing.payload const agentType: AgentType | undefined = payload.agentType // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn. @@ -816,6 +827,10 @@ export class AgentHookServer { if (!existing) { return false } + // Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } const payload = existing.payload // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. if ( @@ -1069,8 +1084,13 @@ export class AgentHookServer { const connectionClearWatermark = payload.connectionId ? this.connectionTimestampWatermarkById.get(payload.connectionId) : undefined - // Why: Date.now() can repeat across reconnect; a remote replay must sort strictly after its connection's transient clear. - const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1) + // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. + const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined + const now = Math.max( + Date.now(), + (connectionClearWatermark ?? -1) + 1, + (restoredStatusWatermark ?? -1) + 1 + ) if (payload.connectionId) { this.connectionTimestampWatermarkById.set(payload.connectionId, now) } @@ -1128,7 +1148,8 @@ export class AgentHookServer { ? { agentType: previous.payload.agentType, state: previous.payload.state, - updatedAt: previous.receivedAt + updatedAt: previous.receivedAt, + restoredUnconfirmed: previous.restoredUnconfirmed } : undefined, incoming: rootContextPreservingPayload.payload.agentType, @@ -1740,6 +1761,7 @@ export class AgentHookServer { | EnrichedAgentHookEventPayload | undefined if ( + !previous?.restoredUnconfirmed && previous?.connectionId === connectionId && previous.tabId === tabId && previous.worktreeId === worktreeId && @@ -2371,8 +2393,12 @@ export class AgentHookServer { const reconciledAt = stateChanged ? Math.max(Date.now(), enriched.receivedAt + 1) : enriched.receivedAt + // Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying + // restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion. + const { restoredUnconfirmed, ...reconciledBase } = enriched const reconciled: EnrichedAgentHookEventPayload = { - ...enriched, + ...reconciledBase, + ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), receivedAt: reconciledAt, stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, payload: { ...enriched.payload, state, subagents } @@ -2505,6 +2531,10 @@ export class AgentHookServer { (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) entry.payload = hydratedPayload } + if (entry.payload.state !== 'done') { + // Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth. + entry.restoredUnconfirmed = true + } this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) if (entry.connectionId) { // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. @@ -2621,6 +2651,8 @@ export class AgentHookServer { const { claudeRunningNonAgentTask: _claudeRunningNonAgentTask, promptInteractionKey: _promptInteractionKey, + // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. + restoredUnconfirmed: _restoredUnconfirmed, launchToken, ...persistedPayload } = payload as EnrichedAgentHookEventPayload diff --git a/src/main/index.ts b/src/main/index.ts index aa3c2b7d493..3521070b55c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1406,6 +1406,7 @@ function openMainWindow(): BrowserWindow { providerSession, providerSessionOnly, promptInteractionKey, + restoredUnconfirmed, isReplay }) => { if (mainWindow?.isDestroyed()) { @@ -1442,6 +1443,7 @@ function openMainWindow(): BrowserWindow { stateStartedAt, ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), + ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(orchestration ? { orchestration } : {}) }) recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 47891ff1f4c..6fb0c2386e3 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -10849,6 +10849,62 @@ describe('OrcaRuntimeService', () => { }) }) + it('does not treat a restored-unconfirmed hook row as live terminal status', async () => { + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = makePaneKey('tab-1', leafId) + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'waiting', + prompt: '', + agentType: 'codex', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + restoredUnconfirmed: true + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'repo terminal', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({ + handle: terminal.handle, + isRunningAgent: false, + status: null + }) + }) + it('does not let stale wait text override a fresh explicit working state', async () => { const leafId = '11111111-1111-4111-8111-111111111111' const paneKey = makePaneKey('tab-1', leafId) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ed1e338c66e..9cae9972a74 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -16322,9 +16322,10 @@ export class OrcaRuntimeService { const consider = ( state: AgentStatusEntry['state'] | undefined, - updatedAt: number | null | undefined + updatedAt: number | null | undefined, + restoredUnconfirmed = false ): void => { - if (!state) { + if (!state || restoredUnconfirmed) { return } if (typeof updatedAt !== 'number' || now - updatedAt > AGENT_STATUS_STALE_AFTER_MS) { @@ -16348,7 +16349,7 @@ export class OrcaRuntimeService { if (entry.terminalHandle !== handle && (!paneKey || entry.paneKey !== paneKey)) { continue } - consider(entry.state, entry.receivedAt) + consider(entry.state, entry.receivedAt, entry.restoredUnconfirmed) } return bestStatus ? { status: bestStatus, updatedAt: bestUpdatedAt } : null @@ -17163,6 +17164,7 @@ export class OrcaRuntimeService { interrupted: boolean stateStartedAt: number updatedAt: number + restoredUnconfirmed?: boolean } >() for (const snapshot of this.latestAgentStatusByPaneKey.values()) { @@ -17207,7 +17209,8 @@ export class OrcaRuntimeService { toolInput: entry.toolInput ?? null, interrupted: entry.interrupted ?? false, stateStartedAt: entry.stateStartedAt, - updatedAt: entry.receivedAt + updatedAt: entry.receivedAt, + ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}) }) } if (rowSources.size === 0) { @@ -17271,7 +17274,8 @@ export class OrcaRuntimeService { toolInput: src.toolInput, interrupted: src.interrupted, stateStartedAt: src.stateStartedAt, - updatedAt: src.updatedAt + updatedAt: src.updatedAt, + ...(src.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}) } // Why: SSH/runtime projections can spell an equivalent path differently; // bucket by the canonical summary id so mobile keeps the agent activity. diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index 794d7f78117..d32192d36a5 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -60,7 +60,8 @@ function makeEntry(overrides: Partial & { paneKey: string }): tabId: overrides.tabId, terminalTitle: overrides.terminalTitle, stateHistory: overrides.stateHistory ?? [], - interrupted: overrides.interrupted + interrupted: overrides.interrupted, + restoredUnconfirmed: overrides.restoredUnconfirmed } } @@ -576,6 +577,82 @@ describe('buildAttentionByWorktree', () => { expect(map.get(w.id)).toEqual({ cls: 2, attentionTimestamp: NOW - 30_000 }) }) + it('keeps a restored row idle while allowing an independently live sibling title', () => { + const w = makeWorktree('wt-1') + const tab = makeTab('tab-1', w.id) + const key = paneKey(tab.id, LEAF_1) + const map = buildAttentionByWorktree( + [w], + { [w.id]: [tab] }, + { + [key]: makeEntry({ + paneKey: key, + state: 'working', + restoredUnconfirmed: true + }) + }, + { [tab.id]: { 1: '⠋ Claude', 2: '✋ Gemini CLI' } }, + ptyMap([tab.id]), + NOW, + undefined, + splitLayout(tab.id) + ) + + expect(map.get(w.id)).toEqual({ + cls: 1, + attentionTimestamp: NOW, + cause: 'title-heuristic' + }) + }) + + it('does not revive a restored row from one title before layout hydration', () => { + const w = makeWorktree('wt-1') + const tab = makeTab('tab-1', w.id) + const key = paneKey(tab.id, LEAF_1) + const map = buildAttentionByWorktree( + [w], + { [w.id]: [tab] }, + { + [key]: makeEntry({ + paneKey: key, + state: 'working', + restoredUnconfirmed: true + }) + }, + { [tab.id]: { 1: '⠋ Claude' } }, + ptyMap([tab.id]), + NOW + ) + + expect(map.get(w.id)).toEqual(IDLE) + }) + + it('still uses one unmapped title when the hook is only age-stale', () => { + const w = makeWorktree('wt-1') + const tab = makeTab('tab-1', w.id) + const key = paneKey(tab.id, LEAF_1) + const map = buildAttentionByWorktree( + [w], + { [w.id]: [tab] }, + { + [key]: makeEntry({ + paneKey: key, + state: 'working', + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + }, + { [tab.id]: { 1: '✋ Gemini CLI' } }, + ptyMap([tab.id]), + NOW + ) + + expect(map.get(w.id)).toEqual({ + cls: 1, + attentionTimestamp: NOW, + cause: 'title-heuristic' + }) + }) + it('per-pane authority across panes: pane A fresh hook=done, pane B no hook + permission title → Class 1', () => { const w = makeWorktree('wt-1') const tab = makeTab('tab-1', w.id) diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 01a551428ca..6c102a41405 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -288,8 +288,11 @@ export function buildAttentionByWorktree( if (hookEntries) { for (const entry of hookEntries) { panes.push({ kind: 'hook', entry }) - // Why: only fresh hook entries suppress the title fallback; a stale one would hide the live title and drop to Class 4. - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + // Why: restored rows own their co-restored title without asserting live state. + if ( + !entry.restoredUnconfirmed && + !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) + ) { continue } const leafId = leafIdFromPaneKey(entry.paneKey) @@ -308,9 +311,12 @@ export function buildAttentionByWorktree( if (paneTitles && Object.keys(paneTitles).length > 0) { // Why: split-pane tabs host multiple agents, one title each; mirrors getWorkingAgentsPerWorktree precedence. const tabLayout = terminalLayoutsByTabId?.[tab.id] - for (const [runtimePaneId, title] of Object.entries(paneTitles)) { + const paneTitleEntries = Object.entries(paneTitles) + for (const [runtimePaneId, title] of paneTitleEntries) { const leafId = resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) - if (leafId !== null && hookLeafIds.has(leafId)) { + const hasSingleUnmappedHook = + leafId === null && hookLeafIds.size === 1 && paneTitleEntries.length === 1 + if ((leafId !== null && hookLeafIds.has(leafId)) || hasSingleUnmappedHook) { continue } panes.push({ diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index 4d7ab9e84d6..913e197691a 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -267,6 +267,27 @@ describe('buildWorktreeAgentRows', () => { expect(done?.state).toBe('done') }) + it('decays a restored-unconfirmed working entry to idle even while recent', () => { + // Why: a hydrated nonterminal row may describe a turn that ended while no + // receiver was up; it must never render as confirmed working, however new. + const updatedAt = 2_000 + const now = updatedAt + 1 + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1'), makeTab('tab-2')], + entries: [ + makeEntry(PANE_KEY_1, updatedAt, { state: 'working', restoredUnconfirmed: true }), + makeEntry(PANE_KEY_2, updatedAt, { state: 'working' }) + ], + retained: [], + now + }) + + const unconfirmed = rows.find((r) => r.paneKey === PANE_KEY_1) + const confirmed = rows.find((r) => r.paneKey === PANE_KEY_2) + expect(unconfirmed?.state).toBe('idle') + expect(confirmed?.state).toBe('working') + }) + it('renders live worktree-attributed entries even when their tab is absent', () => { const rows = buildWorktreeAgentRows({ tabs: [], diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 26c5d2828df..bc1f06cc7b5 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -15,6 +15,7 @@ function makeAgentStatusEntry(args: { state: AgentStatusEntry['state'] worktreeId?: string parentPaneKey?: string + restoredUnconfirmed?: true }): AgentStatusEntry { return { paneKey: args.paneKey, @@ -24,6 +25,7 @@ function makeAgentStatusEntry(args: { stateStartedAt: 1_000, stateHistory: [], worktreeId: args.worktreeId, + restoredUnconfirmed: args.restoredUnconfirmed, orchestration: args.parentPaneKey ? { taskId: 'task-1', @@ -165,6 +167,33 @@ describe('selectWorktreeAgentActivitySummary', () => { expect(nowSpy).toHaveBeenCalledTimes(2) }) + it('lets an unconfirmed restored row suppress only its pane title', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const paneKey = makePaneKey('tab-1', LEAF_ID) + const summary = selectWorktreeAgentActivitySummary( + { + tabsByWorktree: { + 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] + }, + agentStatusEpoch: 0, + agentStatusByPaneKey: { + [paneKey]: makeAgentStatusEntry({ + paneKey, + state: 'working', + restoredUnconfirmed: true + }) + }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + }, + 'repo::/wt-1' + ) + + expect(summary).toMatchObject({ hasLiveWorking: false, hasPermission: false }) + expect(summary.agentStatusPaneIdsByTabId['tab-1']).toEqual(new Set([LEAF_ID])) + }) + it('limits summary-reference churn to the transitioning worktree at scale', () => { vi.spyOn(Date, 'now').mockReturnValue(2_000) const worktreeIds = Array.from({ length: 12 }, (_, index) => `repo::/wt-${index}`) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 0d3810fe53f..38e585d39ce 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -107,10 +107,17 @@ function getWorktreeAgentActivitySummaries( runtimeAgentOrchestrationByPaneKey?.[paneKey] ) const worktreeId = resolveAgentStatusWorktreeId(entry, tabIdToWorktreeId, orchestration) - if (!worktreeId || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + if (!worktreeId) { continue } const summary = summaryForWorktree(worktreeId) + if (entry.restoredUnconfirmed) { + addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) + continue + } + if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + continue + } addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) if (entry.state === 'done') { addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index 2f6cbcadcd8..8f1ea50121c 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -108,6 +108,39 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('working') }) + it('does not revive an unconfirmed restored row from its preserved title', () => { + const restored = entry(FIRST_LEAF_ID, 'working', { restoredUnconfirmed: true }) + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'Codex working' }, + agentStatusByPaneKey: { [restored.paneKey]: restored }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('active') + }) + + it('keeps an independently live sibling title visible beside an unconfirmed row', () => { + const restored = entry(FIRST_LEAF_ID, 'working', { restoredUnconfirmed: true }) + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [restored.paneKey]: restored }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working', 2: 'Claude working' } }, + ptyIdsByTabId: LIVE_PTY, + terminalLayout: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: FIRST_LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: FIRST_LEAF_ID, + expandedLeafId: null + } + }) + ).toBe('working') + }) + it('de-spins a stale working tab on an epoch bump without a new map reference', () => { // Why: the freshness scheduler bumps agentStatusEpoch (not the map ref) at // the 30m stale boundary. The flag cache must invalidate on that bump, or an diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index b64ba41b0f5..611a6ca86ae 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -56,22 +56,21 @@ function getTerminalTabActivityFlags( const now = Date.now() for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey ?? {})) { const identity = parseAgentStatusPaneKey(entry.paneKey || paneKey) + if (!identity) { + continue + } + if (entry.restoredUnconfirmed) { + const flags = getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId) + flags.paneIds.add(identity.paneId) + continue + } // Why: stale hook entries (>30m) are not authority; a slept/abandoned pane // must not keep a tab spinning. Same freshness gate as the sidebar. - if (!identity || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { continue } - let flags = flagsByTabId.get(identity.tabId) - if (!flags) { - flags = { - hasPermission: false, - hasLiveWorking: false, - hasLiveDone: false, - paneIds: new Set() - } - flagsByTabId.set(identity.tabId, flags) - } + const flags = getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId) flags.paneIds.add(identity.paneId) if (entry.state === 'blocked' || entry.state === 'waiting') { flags.hasPermission = true @@ -89,6 +88,23 @@ function getTerminalTabActivityFlags( return flagsByTabId } +function getOrCreateTerminalTabActivityFlags( + flagsByTabId: Map, + tabId: string +): TerminalTabActivityFlags { + let flags = flagsByTabId.get(tabId) + if (!flags) { + flags = { + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: false, + paneIds: new Set() + } + flagsByTabId.set(tabId, flags) + } + return flags +} + // Why: mirror the sidebar summary's parse — live entries on restored/imported // sessions can still carry pre-UUID numeric pane keys. Keep the numeric pane id // so the title-heuristic dedup in resolveWorktreeStatus can still match them. diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 3c8b19068c2..f7f66f0ceac 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3203,12 +3203,18 @@ export function useIpcEvents(): void { const statusPayloadWithTurnBoundary = data.promptInteractionKey ? { ...statusPayload, promptInteractionKey: data.promptInteractionKey } : statusPayload + // Why: hydrated-unconfirmed provenance is envelope data the payload whitelist above drops; re-thread it or freshness gates confirm restored rows. + const statusPayloadWithProvenance = + data.restoredUnconfirmed === true + ? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true } + : statusPayloadWithTurnBoundary const identity = resolveAgentStatusIdentity({ existing: existingStatus ? { agentType: existingStatus.agentType, state: existingStatus.state, - updatedAt: existingStatus.updatedAt + updatedAt: existingStatus.updatedAt, + restoredUnconfirmed: existingStatus.restoredUnconfirmed } : undefined, incoming: statusPayload.agentType, @@ -3241,7 +3247,7 @@ export function useIpcEvents(): void { const statusWorktreeId = data.worktreeId ?? owningWorktreeId store.setAgentStatus( paneKey, - statusPayloadWithTurnBoundary, + statusPayloadWithProvenance, terminalTitle, { updatedAt: data.receivedAt, diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts index 4d67758951a..4c9c49ad9bb 100644 --- a/src/renderer/src/lib/pane-agent-evidence.ts +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -15,11 +15,12 @@ import { // (Moved here from agent-status.ts so the evidence resolvers below and the // aggregate consumers share one gate without an import cycle.) export function isExplicitAgentStatusFresh( - entry: Pick, + entry: Pick, now: number, staleAfterMs: number ): boolean { - return now - entry.updatedAt <= staleAfterMs + // Why: an unconfirmed hydrated row may describe a turn that ended while no receiver was up; never fresh. + return entry.restoredUnconfirmed !== true && now - entry.updatedAt <= staleAfterMs } /** diff --git a/src/renderer/src/lib/running-agent-targets.test.ts b/src/renderer/src/lib/running-agent-targets.test.ts index 847052d2e79..addf1741fa8 100644 --- a/src/renderer/src/lib/running-agent-targets.test.ts +++ b/src/renderer/src/lib/running-agent-targets.test.ts @@ -294,6 +294,36 @@ describe('running agent send targets', () => { expect(target).not.toHaveProperty('disabledReason') }) + it('does not promote an unconfirmed restored row from its preserved title', () => { + const paneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) + const target = resolveRunningAgentSendTarget( + state({ + agentStatusByPaneKey: { + [paneKey]: { ...entry(paneKey, 'working'), restoredUnconfirmed: true } + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: RIGHT_LEAF_ID }, + activeLeafId: RIGHT_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' } + } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex ready' } } + }), + WORKTREE_ID, + paneKey, + NOW + ) + + expect(target).toMatchObject({ + paneKey, + ptyId: 'pty-right', + status: 'disabled', + disabledReason: 'Agent status is stale' + }) + }) + it('treats a missing tab title as absent live title evidence', () => { const paneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) const target = resolveRunningAgentSendTarget( diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index eedc06e36ba..e483b04b4a9 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -71,7 +71,9 @@ export function deriveRunningAgentSendTargets( const liveTitleStatus = ptyId ? detectLiveAgentPaneStatus(state, parsed.tabId, parsed.leafId, tab.title) : null - if (decision.hookState === null) { + if (entry.restoredUnconfirmed) { + disabledReason = 'Agent status is stale' + } else if (decision.hookState === null) { if (liveTitleStatus === 'permission') { disabledReason = 'Agent needs permission' } else if (liveTitleStatus === null) { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index e406d8644f0..01836b7955a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -1896,6 +1896,61 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.sortEpoch).toBe(1) }) + it('applies a marker-only host restart degradation to mirrored agent status', () => { + const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) + const snapshot = makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'codex [working]', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: 'fix web parity', + updatedAt: NOW - 100, + stateStartedAt: NOW - 1_000, + agentType: 'codex', + paneKey: hostPaneKey, + tabId: 'host-tab-1', + worktreeId: WT, + stateHistory: [] + } + } + ]) + const initial = applyWebSessionTabsSnapshot( + makeState(), + snapshot, + ENV, + NOW + ) as Partial + const mirroredPaneKey = Object.keys(initial.agentStatusByPaneKey ?? {})[0]! + const degraded = applyWebSessionTabsSnapshot( + makeState({ ...initial }), + { + ...snapshot, + snapshotVersion: 2, + tabs: snapshot.tabs.map((tab) => + tab.type === 'terminal' && tab.agentStatus + ? { + ...tab, + agentStatus: { ...tab.agentStatus, restoredUnconfirmed: true } + } + : tab + ) + }, + ENV, + NOW + ) as Partial + + expect(degraded.agentStatusByPaneKey?.[mirroredPaneKey]?.restoredUnconfirmed).toBe(true) + expect(degraded.agentStatusEpoch).toBe(2) + expect(degraded.sortEpoch).toBe(2) + }) + it('repairs mirrored same-state attribution and retains identity from an older snapshot', () => { const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) const snapshot = makeSnapshot([ diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 0b8b3908675..d944272accb 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -801,10 +801,13 @@ function buildMirroredAgentStatusPatch( changed = true const entryAttributionChanged = existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId + const entryFreshnessChanged = + !!existing && isAgentStatusFresh(existing, now) !== isAgentStatusFresh(entry, now) const entrySortRelevantChange = !existing || existing.state !== entry.state || !isAgentStatusFresh(existing, now) || + entryFreshnessChanged || entryAttributionChanged || isMirroredCommandCodeTurnBump(existing, entry) aggregateRelevantChange = aggregateRelevantChange || entrySortRelevantChange @@ -1395,13 +1398,17 @@ function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEn a.lastAssistantMessage === b.lastAssistantMessage && a.interrupted === b.interrupted && a.promptInteractionKey === b.promptInteractionKey && + a.restoredUnconfirmed === b.restoredUnconfirmed && agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) && sameAgentStateHistory(a.stateHistory, b.stateHistory) ) } -function isAgentStatusFresh(entry: Pick, now: number): boolean { - return now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS +function isAgentStatusFresh( + entry: Pick, + now: number +): boolean { + return entry.restoredUnconfirmed !== true && now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS } function isMirroredCommandCodeTurnBump( diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 6954fcc7034..70c334eae1a 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -603,6 +603,38 @@ describe('agent status tool + assistant fields', () => { expect(setGeneratedTabTitleFromAgentPrompt).toHaveBeenLastCalledWith('tab-1:1', 'parent codex') }) + it('does not let restored-unconfirmed identity suppress a live terminal status', () => { + vi.useFakeTimers() + const store = createTestStore() + store.getState().setAgentStatus( + 'tab-1:1', + { + state: 'working', + prompt: 'stale codex turn', + agentType: 'codex', + restoredUnconfirmed: true + }, + 'codex', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'done', prompt: 'live claude turn', agentType: 'claude' }, + 'claude', + { updatedAt: 1_100, stateStartedAt: 1_100 } + ) + + expect(store.getState().agentStatusByPaneKey['tab-1:1']).toMatchObject({ + state: 'done', + prompt: 'live claude turn', + agentType: 'claude' + }) + expect(store.getState().agentStatusByPaneKey['tab-1:1'].restoredUnconfirmed).toBeUndefined() + }) + it('allows pane agentType to change after the prior turn is done', () => { vi.useFakeTimers() const store = createTestStore() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index dc2fde9f603..d8c87362edc 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -156,6 +156,7 @@ export type AgentStatusSlice = { payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext promptInteractionKey?: string + restoredUnconfirmed?: boolean }, terminalTitle?: string, timing?: { updatedAt?: number; stateStartedAt?: number }, @@ -1712,7 +1713,8 @@ export const createAgentStatusSlice: StateCreator { vi.restoreAllMocks() }) +describe('isFreshNonDoneAgentStatus', () => { + it('treats a within-TTL working entry as fresh', () => { + expect(isFreshNonDoneAgentStatus({ state: 'working', updatedAt: 1_000 }, 2_000)).toBe(true) + }) + + it('never treats a restored-unconfirmed entry as fresh, regardless of age', () => { + expect( + isFreshNonDoneAgentStatus( + { state: 'working', updatedAt: 1_999, restoredUnconfirmed: true }, + 2_000 + ) + ).toBe(false) + }) + + it('stays false for done and for stale entries', () => { + expect(isFreshNonDoneAgentStatus({ state: 'done', updatedAt: 2_000 }, 2_000)).toBe(false) + expect(isFreshNonDoneAgentStatus({ state: 'working', updatedAt: 0 }, 10_000, 5_000)).toBe(false) + }) +}) + describe('parseAgentStatusPayload', () => { it('parses a valid working payload', () => { const result = parseAgentStatusPayload( diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 157fc95a050..1f2c3680048 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -137,6 +137,10 @@ export type AgentStatusEntry = { providerSession?: AgentProviderSessionMetadata /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string + /** True for a nonterminal state hydrated from last-status.json with no live hook since: + * the transition may have been missed while no receiver was up, so freshness gates + * treat the row as stale immediately. Cleared by any accepted live event. */ + restoredUnconfirmed?: boolean } export type MigrationUnsupportedPtyEntry = { @@ -200,6 +204,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { providerSessionOnly?: boolean /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string + /** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */ + restoredUnconfirmed?: boolean } /** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */ @@ -228,11 +234,17 @@ export const AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH = 16000 export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000 export function isFreshNonDoneAgentStatus( - entry: Pick | undefined, + entry: Pick | undefined, now = Date.now(), staleAfterMs = AGENT_STATUS_STALE_AFTER_MS ): boolean { - return Boolean(entry && entry.state !== 'done' && now - entry.updatedAt <= staleAfterMs) + // Why: an unconfirmed hydrated row may describe a turn that ended while no receiver was up; never fresh. + return Boolean( + entry && + entry.state !== 'done' && + entry.restoredUnconfirmed !== true && + now - entry.updatedAt <= staleAfterMs + ) } // Why: ReadonlySet so .has() accepts any string without a cast here; the narrowing cast stays on the return line where it's proven safe. diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index c8291e35fb1..125d0d588ad 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -740,6 +740,8 @@ export type RuntimeWorktreeAgentRow = { /** When the current `state` was first reported (ms). Drives "Xm ago". */ stateStartedAt: number updatedAt: number + /** See AgentStatusEntry.restoredUnconfirmed — set for hydrated nonterminal rows so clients don't render them as confirmed activity. */ + restoredUnconfirmed?: boolean } export type RuntimeWorktreePsSummary = { From 637c7e94c9b57347358fdf4cef349eb05994e1a4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:32:13 -0700 Subject: [PATCH 006/101] Add SSH config host picker to add-host dialog (#12334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ssh): add SSH config host picker for add-host form Users can now click 'Fill from ~/.ssh/config…' to browse available SSH config hosts in a picker, select one, and have the form automatically prefill with resolved connection details (hostname, port, username, auth). Previously, an 'import' button provided bulk sync on this form—confusing and unhelpful when everything was already synced. That action is now available as a secondary 'Add all' option in the picker. * fix(ssh): import filter preservation and label fallback - Reuse search loader on import completion to preserve active filter inside generation guard - Fall back to hostname when manual host has no label, not empty string - Make alias duplicate detection case-insensitive to match config picker behavior - Validate host availability when restoring project group selection - Add aria-selected attribute to picker options for accessibility * fix(ssh): harden config picker import, alias folding, and host targeting Review findings on the ~/.ssh/config picker + bulk add: - Guard config-host resolution with a generation counter so a late resolve cannot overwrite a later pick or a form the user backed out of; freeze the other rows while a pick resolves. - Stop "Add all N" from re-adopting deleted hosts — it now imports without reAdopt, matching the new-host count it advertises. Settings → Import keeps the explicit re-adopt path. - Fold SSH aliases through a shared normalizeSshConfigAlias for import ownership, delete tombstones, reclaim, picker search, and the save-time duplicate check, which now occupies configHost *and* label like the picker. - Persist GSSAPIAuthentication only when a parsed Host entry asks for it, not when `ssh -G` merely echoes the /etc/ssh system default. - Fail closed with unavailable/setup-not-found when an explicit projectHostSetupId names a non-actionable host instead of silently creating the workspace on a sibling host. - Cache the parsed config for the picker session (refresh on open/retry) so filter keystrokes no longer reparse and Include-expand the file, keep the filter usable during loads, add a Retry on load errors, explain an empty Identity file after a config fill, and drop the always-false aria-selected. * refactor(ssh): centralize host result limit and extract folder group val Move SSH_CONFIG_HOST_RESULT_LIMIT to shared types so the renderer's limit message cannot drift from the host's query limit. Extract findActionableFolderProjectGroup to avoid repeating the folder-host-availability check across the composer hook. * fix(ssh): pass -F to ssh -G when HOME differs from passwd home In E2E tests and sandboxes, isolated HOME can differ from the system passwd home. OpenSSH resolves the default config via getpwuid (passwd), while Node's loadUserSshConfig uses os.homedir() (HOME-aware). Pass -F to explicitly specify the config path when they diverge, so ssh -G and the picker resolve the same file. * fix(ssh): verify config host exists before resolving with ssh -G When a user edits ~/.ssh/config and removes a host, the import picker should not fall back to ssh -G's echoed response (which treats any alias as valid). Check the reloaded config file before resolving. - Force reload config on each resolve to catch user edits post-open - Reject aliases not in the current config before calling ssh -G - Add test for deleted alias edge case - Fix workspace-target fallback to honor explicit host selection * fix(ssh): let tombstoned aliases be re-picked in the config picker Allow users to reclaim a deleted SSH host by re-picking it from ~/.ssh/config. Tombstoned aliases now appear in the picker with a "Removed from Orca" badge and remain pickable, but don't count toward "Add all" operations — ensuring passive import never resurrects a deleted alias while still giving the user a recovery path. --- src/main/ipc/ssh.test.ts | 47 ++ src/main/ipc/ssh.ts | 22 + src/main/ssh/ssh-config-host-picker.test.ts | 198 +++++++++ src/main/ssh/ssh-config-host-picker.ts | 167 +++++++ src/main/ssh/ssh-config-parser.test.ts | 60 +++ src/main/ssh/ssh-connection-store.test.ts | 80 +++- src/main/ssh/ssh-connection-store.ts | 51 ++- src/main/ssh/ssh-connection.test.ts | 24 ++ src/main/ssh/ssh-g-config-resolution.test.ts | 63 +++ src/main/ssh/ssh-g-config-resolution.ts | 50 ++- .../ssh/ssh-multi-key-authentication.test.ts | 23 + src/preload/api-types.ts | 5 + src/preload/index.ts | 9 + .../NewWorkspaceComposerCard.test.tsx | 4 +- .../ProjectHostSetupCombobox.test.tsx | 4 +- .../settings/RepositoryHostSetupsSection.tsx | 1 + .../settings/ssh-target-draft.test.ts | 60 +++ .../components/settings/ssh-target-draft.ts | 25 ++ .../settings/ssh-target-save-payload.ts | 2 + ...AddRemoteHostDialog.config-picker.test.tsx | 211 +++++++++ .../sidebar/AddRemoteHostDialog.tsx | 407 +++++++++--------- .../sidebar/AddRemoteHostFields.tsx | 17 +- .../sidebar/AddRemoteHostServerFormPanel.tsx | 76 ++++ .../sidebar/AddRemoteHostSshConfigPicker.tsx | 276 ++++++++++++ .../sidebar/AddRemoteHostSshFormPanel.tsx | 75 ++++ .../add-remote-host-ssh-actions.test.ts | 211 +++++++++ .../sidebar/add-remote-host-ssh-actions.ts | 266 ++++++++++++ .../sidebar/ssh-target-duplicate.test.ts | 61 +++ .../sidebar/ssh-target-duplicate.ts | 33 ++ src/renderer/src/hooks/useComposerState.ts | 96 +++-- src/renderer/src/i18n/locales/en.json | 33 +- src/renderer/src/i18n/locales/es.json | 2 - src/renderer/src/i18n/locales/ja.json | 2 - src/renderer/src/i18n/locales/ko.json | 2 - src/renderer/src/i18n/locales/zh.json | 2 - .../lib/new-workspace-project-options.test.ts | 86 +++- .../src/lib/new-workspace-project-options.ts | 55 ++- .../lib/project-host-setup-options.test.ts | 21 +- .../src/lib/project-host-setup-options.ts | 7 +- .../lib/project-host-workspace-target.test.ts | 63 +++ .../src/lib/project-host-workspace-target.ts | 34 +- src/renderer/src/web/web-preload-api.ts | 9 + src/shared/execution-host-registry.test.ts | 35 ++ src/shared/execution-host-registry.ts | 47 +- src/shared/ssh-config-alias.ts | 8 + src/shared/ssh-types.ts | 48 +++ tests/e2e/helpers/ssh-config-host-picker.ts | 282 ++++++++++++ tests/e2e/ssh-config-host-import.spec.ts | 202 +++++++++ tests/e2e/ssh-config-host-picker.PLAN.md | 188 ++++++++ tests/e2e/ssh-config-host-picker.spec.ts | 187 ++++++++ 50 files changed, 3613 insertions(+), 324 deletions(-) create mode 100644 src/main/ssh/ssh-config-host-picker.test.ts create mode 100644 src/main/ssh/ssh-config-host-picker.ts create mode 100644 src/main/ssh/ssh-g-config-resolution.test.ts create mode 100644 src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx create mode 100644 src/renderer/src/components/sidebar/AddRemoteHostServerFormPanel.tsx create mode 100644 src/renderer/src/components/sidebar/AddRemoteHostSshConfigPicker.tsx create mode 100644 src/renderer/src/components/sidebar/AddRemoteHostSshFormPanel.tsx create mode 100644 src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts create mode 100644 src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts create mode 100644 src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts create mode 100644 src/renderer/src/components/sidebar/ssh-target-duplicate.ts create mode 100644 src/shared/ssh-config-alias.ts create mode 100644 tests/e2e/helpers/ssh-config-host-picker.ts create mode 100644 tests/e2e/ssh-config-host-import.spec.ts create mode 100644 tests/e2e/ssh-config-host-picker.PLAN.md create mode 100644 tests/e2e/ssh-config-host-picker.spec.ts diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index c7f850452c5..7a7b01563e1 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -20,6 +20,8 @@ const { mockRegisterSshGitProvider, mockPortForwardManager, mockPortScannerCallbacks, + mockListConfigHosts, + mockResolveConfigHost, mockNextConnectionManagers, mockNextPortForwardManagers } = vi.hoisted(() => ({ @@ -28,6 +30,7 @@ const { powerMonitorOnMock: vi.fn(), mockSshStore: { listTargets: vi.fn().mockReturnValue([]), + listSuppressedSshConfigAliases: vi.fn().mockReturnValue([]), getTarget: vi.fn(), addTarget: vi.fn(), updateTarget: vi.fn(), @@ -88,10 +91,23 @@ const { callbacksRef: { current: null as unknown } }, mockPortScannerCallbacks: new Map(), + mockListConfigHosts: vi.fn().mockReturnValue({ + hosts: [], + totalHostCount: 0, + newHostCount: 0, + matchCount: 0, + hasMore: false + }), + mockResolveConfigHost: vi.fn().mockResolvedValue(null), mockNextConnectionManagers: [] as unknown[], mockNextPortForwardManagers: [] as unknown[] })) +vi.mock('../ssh/ssh-config-host-picker', () => ({ + listUserSshConfigHostSummaries: mockListConfigHosts, + resolveUserSshConfigHost: mockResolveConfigHost +})) + vi.mock('electron', () => ({ ipcMain: { handle: handleMock, @@ -436,6 +452,8 @@ describe('SSH IPC handlers', () => { expect(channels).toContain('ssh:updateTarget') expect(channels).toContain('ssh:removeTarget') expect(channels).toContain('ssh:importConfig') + expect(channels).toContain('ssh:listConfigHosts') + expect(channels).toContain('ssh:resolveConfigHost') expect(channels).toContain('ssh:connect') expect(channels).toContain('ssh:disconnect') expect(channels).toContain('ssh:terminateSessions') @@ -549,6 +567,35 @@ describe('SSH IPC handlers', () => { expect(result).toEqual({ targets: imported, repoReadoptions: [] }) }) + it('ssh:listConfigHosts loads summaries against current targets', async () => { + mockSshStore.listTargets.mockReturnValue([ + { id: 'ssh-1', label: 'other', host: 'other.com', port: 22, username: 'x' } + ]) + + const result = await handlers.get('ssh:listConfigHosts')!(null, { query: 'oth' }) + + expect(mockSshStore.listTargets).toHaveBeenCalled() + expect(mockListConfigHosts).toHaveBeenCalledWith(mockSshStore.listTargets(), 'oth', [], { + refresh: false + }) + expect(result).toMatchObject({ hosts: [], hasMore: false }) + }) + + // Only a picker (re)open re-reads ~/.ssh/config; filter keystrokes reuse the parse. + it('ssh:listConfigHosts refreshes the parsed config only when asked', async () => { + mockSshStore.listTargets.mockReturnValue([]) + + await handlers.get('ssh:listConfigHosts')!(null, { query: '', refresh: true }) + + expect(mockListConfigHosts).toHaveBeenCalledWith([], '', [], { refresh: true }) + }) + + it('ssh:resolveConfigHost resolves only the selected alias', async () => { + await handlers.get('ssh:resolveConfigHost')!(null, { alias: 'prod' }) + + expect(mockResolveConfigHost).toHaveBeenCalledWith('prod') + }) + it('ssh:connect throws for unknown targetId', async () => { mockSshStore.getTarget.mockReturnValue(undefined) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 6bae75a4424..0dd32ce534e 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -3,6 +3,10 @@ import { ipcMain, powerMonitor, type BrowserWindow } from 'electron' import { appendFileSync } from 'node:fs' import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' +import { + listUserSshConfigHostSummaries, + resolveUserSshConfigHost +} from '../ssh/ssh-config-host-picker' import type { SshConnectionCallbacks } from '../ssh/ssh-connection' import { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' @@ -13,6 +17,7 @@ import type { DetectedPort, EnrichedDetectedPort, SavedPortForward, + SshConfigHostListArgs, SshRepoReadoption, SshTarget, SshConnectionStatus, @@ -71,6 +76,8 @@ const SSH_IPC_CHANNELS = [ 'ssh:updateTarget', 'ssh:removeTarget', 'ssh:importConfig', + 'ssh:listConfigHosts', + 'ssh:resolveConfigHost', 'ssh:connect', 'ssh:disconnect', 'ssh:terminateSessions', @@ -983,6 +990,21 @@ export function registerSshHandlers( return { targets, repoReadoptions } }) + // Why: add-host dialog picks one config entry to prefill the form; does not + // mutate the target store (bulk sync stays on Settings → Import). + ipcMain.handle('ssh:listConfigHosts', (_event, args?: SshConfigHostListArgs) => { + return listUserSshConfigHostSummaries( + sshStore!.listTargets(), + args?.query, + sshStore!.listSuppressedSshConfigAliases(), + { refresh: args?.refresh === true } + ) + }) + + ipcMain.handle('ssh:resolveConfigHost', (_event, args: { alias: string }) => { + return resolveUserSshConfigHost(args.alias) + }) + // ── Connection lifecycle ─────────────────────────────────────────── async function connectTarget(targetId: string): Promise { diff --git a/src/main/ssh/ssh-config-host-picker.test.ts b/src/main/ssh/ssh-config-host-picker.test.ts new file mode 100644 index 00000000000..4d7666fa457 --- /dev/null +++ b/src/main/ssh/ssh-config-host-picker.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as SshConfigParser from './ssh-config-parser' +import type { SshConfigHost } from './ssh-config-parser' + +const loadUserSshConfigMock = vi.hoisted(() => vi.fn<() => SshConfigHost[]>(() => [])) +vi.mock('./ssh-config-parser', async (importOriginal) => ({ + ...(await importOriginal()), + loadUserSshConfig: loadUserSshConfigMock +})) + +import { SSH_CONFIG_HOST_RESULT_LIMIT } from '../../shared/ssh-types' +import { + invalidateUserSshConfigHostCache, + listUserSshConfigHostSummaries, + resolveUserSshConfigHost, + searchSshConfigHosts +} from './ssh-config-host-picker' +import type { SshResolvedConfig } from './ssh-g-config-resolution' + +function resolved(overrides: Partial = {}): SshResolvedConfig { + return { + hostname: 'prod.internal', + user: 'deploy', + port: 2222, + identityFile: ['/keys/first', '/keys/second'], + identitiesOnly: true, + forwardAgent: false, + gssapiAuthentication: true, + proxyUseFdpass: false, + controlMaster: 'no', + controlPersist: 'no', + ...overrides + } +} + +describe('SSH config host picker search', () => { + it('bounds results while searching aliases beyond the first page', () => { + const hosts = Array.from({ length: 150_000 }, (_, index) => ({ + host: `generated-${index}` + })) + const initial = searchSshConfigHosts(hosts, []) + const last = searchSshConfigHosts(hosts, [], 'generated-149999') + + expect(initial.hosts).toHaveLength(SSH_CONFIG_HOST_RESULT_LIMIT) + expect(initial).toMatchObject({ + totalHostCount: 150_000, + newHostCount: 150_000, + matchCount: 150_000, + hasMore: true + }) + expect(last.hosts.map((host) => host.alias)).toEqual(['generated-149999']) + expect(last.hasMore).toBe(false) + }) + + it('counts de-duplicated and existing aliases while matching effective display fields', () => { + const result = searchSshConfigHosts( + [ + { host: 'prod', hostname: 'prod.internal', user: 'deploy' }, + { host: 'prod', hostname: 'ignored.internal' }, + { host: 'stage', hostname: 'stage.internal', user: 'ops' } + ], + [{ configHost: 'prod', label: 'Production' }], + 'internal' + ) + + expect(result).toMatchObject({ + totalHostCount: 2, + newHostCount: 1, + matchCount: 2, + hasMore: false + }) + expect(result.hosts[0]).toMatchObject({ alias: 'prod', alreadyInOrca: true }) + }) + + it('marks a case-only alias variant as already in Orca', () => { + const result = searchSshConfigHosts([{ host: 'prod' }], [{ configHost: 'Prod', label: 'Prod' }]) + + expect(result).toMatchObject({ totalHostCount: 1, newHostCount: 0 }) + expect(result.hosts[0]).toMatchObject({ alias: 'prod', alreadyInOrca: true }) + }) + + it('lists suppressed aliases for re-pick but excludes them from Add-all newHostCount', () => { + const result = searchSshConfigHosts([{ host: 'removed' }, { host: 'active' }], [], '', [ + 'removed' + ]) + + expect(result).toMatchObject({ totalHostCount: 2, newHostCount: 1, matchCount: 2 }) + expect(result.hosts).toEqual([ + expect.objectContaining({ alias: 'removed', previouslyRemoved: true, alreadyInOrca: false }), + expect.objectContaining({ alias: 'active', alreadyInOrca: false }) + ]) + expect(result.hosts.find((host) => host.alias === 'active')?.previouslyRemoved).toBeUndefined() + }) + + it('does not mark a host as previously removed when it is already in Orca', () => { + const result = searchSshConfigHosts( + [{ host: 'prod' }], + [{ configHost: 'prod', label: 'Production' }], + '', + ['prod'] + ) + + expect(result.hosts[0]).toMatchObject({ + alias: 'prod', + alreadyInOrca: true + }) + expect(result.hosts[0]?.previouslyRemoved).toBeUndefined() + expect(result.newHostCount).toBe(0) + }) +}) + +describe('listUserSshConfigHostSummaries caching', () => { + beforeEach(() => { + loadUserSshConfigMock.mockClear() + loadUserSshConfigMock.mockReturnValue([{ host: 'prod' }, { host: 'stage' }]) + invalidateUserSshConfigHostCache() + }) + + it('parses ~/.ssh/config once per picker session and filters in memory', () => { + listUserSshConfigHostSummaries([], '', [], { refresh: true }) + const filtered = listUserSshConfigHostSummaries([], 'stage') + listUserSshConfigHostSummaries([], 'sta') + + expect(loadUserSshConfigMock).toHaveBeenCalledTimes(1) + expect(filtered.hosts.map((host) => host.alias)).toEqual(['stage']) + }) + + it('re-reads the file when the picker reopens', () => { + listUserSshConfigHostSummaries([], '', [], { refresh: true }) + loadUserSshConfigMock.mockReturnValue([{ host: 'prod' }, { host: 'stage' }, { host: 'added' }]) + const reopened = listUserSshConfigHostSummaries([], '', [], { refresh: true }) + + expect(loadUserSshConfigMock).toHaveBeenCalledTimes(2) + expect(reopened.totalHostCount).toBe(3) + }) +}) + +describe('resolveUserSshConfigHost', () => { + it('returns effective endpoint and the complete authentication contract', async () => { + const resolver = vi.fn().mockResolvedValue(resolved()) + + await expect( + resolveUserSshConfigHost('prod', resolver, () => [ + { host: 'prod', gssapiAuthentication: true } + ]) + ).resolves.toEqual({ + alias: 'prod', + hostname: 'prod.internal', + username: 'deploy', + port: 2222, + identityFiles: ['/keys/first', '/keys/second'], + identitiesOnly: true, + forwardAgent: false, + gssapiAuthentication: true, + proxyUseFdpass: false + }) + expect(resolver).toHaveBeenCalledWith('prod') + }) + + it('ignores the system-wide GSSAPIAuthentication default that ssh -G reports', async () => { + const resolver = vi.fn().mockResolvedValue(resolved()) + + await expect( + resolveUserSshConfigHost('prod', resolver, () => [ + { host: 'prod', hostname: 'prod.internal' } + ]) + ).resolves.toMatchObject({ gssapiAuthentication: false }) + }) + + it('honours a case-only variant of the Host entry that requests GSSAPI', async () => { + const resolver = vi.fn().mockResolvedValue(resolved()) + + await expect( + resolveUserSshConfigHost('PROD', resolver, () => [ + { host: 'prod', gssapiAuthentication: true } + ]) + ).resolves.toMatchObject({ gssapiAuthentication: true }) + }) + + it('returns null for an alias the config no longer declares, even though ssh -G answers', async () => { + const resolver = vi.fn().mockResolvedValue(resolved({ hostname: 'deleted' })) + + await expect( + resolveUserSshConfigHost('deleted', resolver, () => [{ host: 'prod' }]) + ).resolves.toBeNull() + expect(resolver).not.toHaveBeenCalled() + }) + + it('returns null when OpenSSH cannot resolve an endpoint', async () => { + await expect( + resolveUserSshConfigHost( + 'missing', + async () => null, + () => [{ host: 'missing' }] + ) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/ssh/ssh-config-host-picker.ts b/src/main/ssh/ssh-config-host-picker.ts new file mode 100644 index 00000000000..d1870ad1b69 --- /dev/null +++ b/src/main/ssh/ssh-config-host-picker.ts @@ -0,0 +1,167 @@ +import type { + SshConfigHostListResult, + SshConfigHostResolution, + SshConfigHostSummary, + SshTarget +} from '../../shared/ssh-types' +import { SSH_CONFIG_HOST_RESULT_LIMIT } from '../../shared/ssh-types' +import { normalizeSshConfigAlias } from '../../shared/ssh-config-alias' +import { loadUserSshConfig, type SshConfigHost } from './ssh-config-parser' +import { resolveWithSshG, type SshResolvedConfig } from './ssh-g-config-resolution' + +// Why: every keystroke in the picker filter re-queries the main process. Parsing (and +// Include-expanding) ~/.ssh/config per keystroke is the whole cost, and the file cannot +// change between them, so hold the parse for the picker session and refresh on reopen. +let cachedConfigHosts: SshConfigHost[] | null = null + +export function invalidateUserSshConfigHostCache(): void { + cachedConfigHosts = null +} + +function getUserSshConfigHosts(refresh: boolean): SshConfigHost[] { + if (refresh || cachedConfigHosts === null) { + cachedConfigHosts = loadUserSshConfig() + } + return cachedConfigHosts +} + +export function searchSshConfigHosts( + hosts: SshConfigHost[], + existingTargets: readonly Pick[], + query = '', + suppressedAliases: readonly string[] = [] +): SshConfigHostListResult { + const existingAliases = new Set( + existingTargets.flatMap((target) => + [target.configHost, target.label].map(normalizeSshConfigAlias).filter(Boolean) + ) + ) + const normalizedQuery = query.trim().toLowerCase() + const suppressedAliasSet = new Set(suppressedAliases.map(normalizeSshConfigAlias)) + const summaries: SshConfigHostSummary[] = [] + const seenAliases = new Set() + let totalHostCount = 0 + let newHostCount = 0 + let matchCount = 0 + + for (const entry of hosts) { + const normalizedAlias = normalizeSshConfigAlias(entry.host) + if (seenAliases.has(normalizedAlias)) { + continue + } + seenAliases.add(normalizedAlias) + const alreadyInOrca = existingAliases.has(normalizedAlias) + // Why: tombstones only block passive bulk import — the picker still lists the + // Host so deleting one Orca target never looks like "~/.ssh/config is empty". + const previouslyRemoved = !alreadyInOrca && suppressedAliasSet.has(normalizedAlias) + totalHostCount += 1 + // Why: "Add all" must match importFromSshConfig without reAdopt (tombstones stay). + newHostCount += alreadyInOrca || previouslyRemoved ? 0 : 1 + if (!matchesQuery(entry, normalizedQuery)) { + continue + } + matchCount += 1 + if (summaries.length < SSH_CONFIG_HOST_RESULT_LIMIT) { + summaries.push(toSummary(entry, alreadyInOrca, previouslyRemoved)) + } + } + + return { + hosts: summaries, + totalHostCount, + newHostCount, + matchCount, + hasMore: matchCount > summaries.length + } +} + +export function listUserSshConfigHostSummaries( + existingTargets: readonly Pick[], + query?: string, + suppressedAliases?: readonly string[], + options?: { refresh?: boolean } +): SshConfigHostListResult { + return searchSshConfigHosts( + getUserSshConfigHosts(options?.refresh === true), + existingTargets, + query, + suppressedAliases + ) +} + +export async function resolveUserSshConfigHost( + alias: string, + resolver: (host: string) => Promise = resolveWithSshG, + // Why: read the file again rather than the picker-session cache — the point of the check + // below is to catch a row the user edited out of ~/.ssh/config after opening the picker. + loadConfigHosts: () => SshConfigHost[] = () => getUserSshConfigHosts(true) +): Promise { + const configHosts = loadConfigHosts() + // Why: `ssh -G` answers for unknown aliases too, echoing the alias back as the hostname. + // Resolving one would mint a target for a host that is no longer configured. + if (!configHostMatches(configHosts, alias)) { + return null + } + const resolved = await resolver(alias) + if (!resolved?.hostname) { + return null + } + return { + alias, + hostname: resolved.hostname, + port: resolved.port, + username: resolved.user ?? '', + identityFiles: resolved.identityFile, + ...(resolved.identityAgent ? { identityAgent: resolved.identityAgent } : {}), + identitiesOnly: resolved.identitiesOnly, + forwardAgent: resolved.forwardAgent, + // Why: `ssh -G` reports the effective value, which on many distros is the /etc/ssh + // default `GSSAPIAuthentication yes`. Stamping that onto a manual target would force + // a GSSAPI-first handshake the user never asked for, so honour the Host entry only. + gssapiAuthentication: configHostRequestsGssapi(configHosts, alias), + ...(resolved.proxyCommand ? { proxyCommand: resolved.proxyCommand } : {}), + proxyUseFdpass: resolved.proxyUseFdpass, + ...(resolved.proxyJump ? { jumpHost: resolved.proxyJump } : {}) + } +} + +function configHostMatches(hosts: readonly SshConfigHost[], alias: string): boolean { + const normalized = normalizeSshConfigAlias(alias) + return Boolean(normalized) && hosts.some((entry) => matchesAlias(entry, normalized)) +} + +function configHostRequestsGssapi(hosts: readonly SshConfigHost[], alias: string): boolean { + const normalized = normalizeSshConfigAlias(alias) + return hosts.some((entry) => matchesAlias(entry, normalized) && entry.gssapiAuthentication) +} + +function matchesAlias(entry: SshConfigHost, normalizedAlias: string): boolean { + return normalizeSshConfigAlias(entry.host) === normalizedAlias +} + +function toSummary( + entry: SshConfigHost, + alreadyInOrca: boolean, + previouslyRemoved = false +): SshConfigHostSummary { + return { + alias: entry.host, + hostname: entry.hostname || entry.host, + port: entry.port ?? 22, + username: entry.user ?? '', + ...(entry.identityFile ? { identityFile: entry.identityFile } : {}), + ...(entry.proxyCommand ? { proxyCommand: entry.proxyCommand } : {}), + ...(entry.proxyJump ? { jumpHost: entry.proxyJump } : {}), + alreadyInOrca, + ...(previouslyRemoved ? { previouslyRemoved: true } : {}) + } +} + +function matchesQuery(entry: SshConfigHost, query: string): boolean { + return ( + !query || + entry.host.toLowerCase().includes(query) || + (entry.hostname ?? entry.host).toLowerCase().includes(query) || + (entry.user ?? '').toLowerCase().includes(query) + ) +} diff --git a/src/main/ssh/ssh-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts index e48b77baab9..8625a553ad1 100644 --- a/src/main/ssh/ssh-config-parser.test.ts +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { join } from 'node:path' import { parseSshConfig, sshConfigHostsToTargets, parseSshGOutput } from './ssh-config-parser' +import { searchSshConfigHosts } from './ssh-config-host-picker' + +const sshConfigHostsToSummaries = ( + hosts: Parameters[0], + targets: Parameters[1] +) => searchSshConfigHosts(hosts, targets).hosts vi.mock('os', () => ({ homedir: () => '/home/testuser' @@ -460,6 +466,60 @@ describe('sshConfigHostsToTargets', () => { }) }) +describe('sshConfigHostsToSummaries', () => { + it('maps hosts for the picker and flags aliases already in Orca', () => { + const hosts = [ + { + host: 'staging', + hostname: 'staging.internal', + user: 'ubuntu', + port: 22, + identityFile: '/home/me/.ssh/staging' + }, + { host: 'prod', hostname: 'prod.example', user: 'ops', port: 2222, proxyJump: 'bastion' } + ] + const summaries = sshConfigHostsToSummaries(hosts, [ + { label: 'staging', configHost: 'staging' } + ]) + expect(summaries).toEqual([ + { + alias: 'staging', + hostname: 'staging.internal', + port: 22, + username: 'ubuntu', + identityFile: '/home/me/.ssh/staging', + alreadyInOrca: true + }, + { + alias: 'prod', + hostname: 'prod.example', + port: 2222, + username: 'ops', + jumpHost: 'bastion', + alreadyInOrca: false + } + ]) + }) + + it('de-dupes aliases and defaults missing fields', () => { + const hosts = [ + { host: 'box' }, + { host: 'box', hostname: 'second.example' }, + { host: 'other', user: 'me' } + ] + const summaries = sshConfigHostsToSummaries(hosts, []) + expect(summaries).toHaveLength(2) + expect(summaries[0]).toEqual({ + alias: 'box', + hostname: 'box', + port: 22, + username: '', + alreadyInOrca: false + }) + expect(summaries[1].alias).toBe('other') + }) +}) + // ── parseSshGOutput ────────────────────────────────────────────────── describe('parseSshGOutput', () => { diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 90d92a1499e..9acba4d18eb 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -85,6 +85,22 @@ describe('SshConnectionStore', () => { expect(mockStore.getSshTargets).toHaveBeenCalled() }) + it('lists picker suppression aliases without consulting re-adoption tombstones', () => { + mockStore.addDeletedSshConfigAlias('config-removed') + mockStore.addRemovedSshTargetTombstone({ + oldTargetId: 'ssh-manual', + configHost: 'manual-removed', + host: 'manual.internal', + port: 22, + username: 'deploy', + label: 'Manual', + removedAt: 1 + }) + + expect(sshStore.listSuppressedSshConfigAliases()).toEqual(['config-removed']) + expect(mockStore.getRemovedSshTargetTombstones).not.toHaveBeenCalled() + }) + it('getTarget delegates to store', () => { sshStore.getTarget('test-id') expect(mockStore.getSshTarget).toHaveBeenCalledWith('test-id') @@ -357,6 +373,65 @@ describe('SshConnectionStore', () => { expect(result).toEqual([]) }) + // SSH matches Host patterns case-insensitively, so `Prod` and `prod` are one host + // everywhere else in the picker — import ownership must agree. + it('treats a case-only alias variant as owned by the existing manual target', () => { + mockStore.addSshTarget({ + id: 'ssh-m', + label: 'Prod', + configHost: 'Prod', + host: 'manual.example.com', + port: 22, + username: 'me', + source: 'manual' + }) + loadUserSshConfigMock.mockReturnValue([{ host: 'prod' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'prod' })]) + + const result = sshStore.importFromSshConfig() + + expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) + expect(result).toEqual([]) + }) + + it('keeps a case-only alias variant suppressed after the host was deleted', () => { + const added = sshStore.addTarget({ + label: 'Prod', + configHost: 'Prod', + host: 'prod.example.com', + port: 22, + username: 'me' + }) + sshStore.removeTarget(added.id) + loadUserSshConfigMock.mockReturnValue([{ host: 'prod' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'prod' })]) + + const result = sshStore.importFromSshConfig() + + expect(result).toEqual([]) + expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) + }) + + it('lifts a tombstone stored under different casing when the host is re-added', () => { + const added = sshStore.addTarget({ + label: 'Prod', + configHost: 'Prod', + host: 'prod.example.com', + port: 22, + username: 'me' + }) + sshStore.removeTarget(added.id) + sshStore.addTarget({ + label: 'prod', + configHost: 'prod', + host: 'prod.example.com', + port: 22, + username: 'me' + }) + + expect(sshStore.listSuppressedSshConfigAliases()).toEqual([]) + }) + it('does not rewrite an unchanged config-sourced target', () => { mockStore.addSshTarget({ id: 'ssh-1', @@ -423,7 +498,7 @@ describe('SshConnectionStore', () => { expect(result).toEqual([]) }) - it('does not tombstone a manual target on delete', () => { + it('suppresses a deleted manual target from config discovery', () => { mockStore.addSshTarget({ id: 'ssh-1', label: 'mini', @@ -435,7 +510,7 @@ describe('SshConnectionStore', () => { }) sshStore.removeTarget('ssh-1') - expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled() + expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini') }) it('re-adding a deleted host reclaims its alias so sync stops suppressing it', () => { @@ -557,6 +632,7 @@ describe('SshConnectionStore', () => { sshStore.removeTarget('runtime-ssh-abc') expect(mockStore.addRemovedSshTargetTombstone).not.toHaveBeenCalled() + expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled() }) it('re-adopts orphaned repos when the same host is re-added', () => { diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index 408b4522afa..97fc2e3b32e 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -1,6 +1,7 @@ import type { Store } from '../persistence' import type { SshRepoReadoption, SshTarget } from '../../shared/ssh-types' import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../shared/execution-host' +import { normalizeSshConfigAlias } from '../../shared/ssh-config-alias' import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser' import { buildRemovedSshTargetTombstone, @@ -25,6 +26,10 @@ export class SshConnectionStore { return labels } + listSuppressedSshConfigAliases(): string[] { + return this.store.getDeletedSshConfigAliases() + } + getTarget(id: string): SshTarget | undefined { return this.store.getSshTarget(id) } @@ -87,29 +92,29 @@ export class SshConnectionStore { removeTarget(id: string): void { const target = this.store.getSshTarget(id) - // Why: deleting a config-managed target must record a tombstone; otherwise - // the next ~/.ssh/config sync re-inserts it verbatim (the config entry still - // exists on disk) and the host reappears. Manual targets need no tombstone — - // sync never re-adds them. - if (target && isConfigManagedTarget(target)) { + if (target && !isRuntimeOwnedSshTarget(target)) { const alias = target.configHost ?? target.label if (alias) { + // Why: tombstone so passive ~/.ssh/config sync does not resurrect the host. + // The config picker still lists it so re-pick/save can reclaim the alias. this.store.addDeletedSshConfigAlias(alias) } - } - // Why: record the removed target's host identity (for ALL user-facing - // targets, config-managed or manual) so a later re-add of the same host can - // re-adopt any workspaces orphaned on this id. Runtime-owned targets manage - // their own lifecycle and are never re-adopted. - if (target && !isRuntimeOwnedSshTarget(target)) { this.store.addRemovedSshTargetTombstone(buildRemovedSshTargetTombstone(target, Date.now())) } this.store.removeSshTarget(id) } private reclaimAlias(alias: string | undefined): void { - if (alias) { - this.store.removeDeletedSshConfigAlias(alias) + const normalized = normalizeSshConfigAlias(alias) + if (!normalized) { + return + } + // Why: tombstones persisted before alias folding (and hosts written with different + // casing) must all be lifted, or a re-add stays suppressed for its case variants. + for (const stored of this.store.getDeletedSshConfigAliases()) { + if (normalizeSshConfigAlias(stored) === normalized) { + this.store.removeDeletedSshConfigAlias(stored) + } } } @@ -126,7 +131,11 @@ export class SshConnectionStore { if (options?.reAdopt) { this.store.clearDeletedSshConfigAliases() } - const deletedAliases = new Set(this.store.getDeletedSshConfigAliases()) + // Why: aliases are compared case-insensitively everywhere else (picker, duplicate + // check, tombstones); a case-sensitive Set here would double-insert `Prod` vs `prod`. + const deletedAliases = new Set( + this.store.getDeletedSshConfigAliases().map((alias) => normalizeSshConfigAlias(alias)) + ) const configHosts = loadUserSshConfig() const existingTargets = this.store.getSshTargets() // Map config-managed targets (and legacy targets that strongly look like @@ -136,7 +145,7 @@ export class SshConnectionStore { const syncableByAlias = new Map() const manualAliases = new Set() for (const existing of existingTargets) { - const alias = existing.configHost ?? existing.label + const alias = normalizeSshConfigAlias(existing.configHost ?? existing.label) if ( existing.source === 'manual' || (existing.source === undefined && !isLegacyConfigImportTarget(existing)) @@ -160,7 +169,7 @@ export class SshConnectionStore { const processedAliases = new Set() for (const candidate of candidates) { - const alias = candidate.configHost ?? candidate.label + const alias = normalizeSshConfigAlias(candidate.configHost ?? candidate.label) if (manualAliases.has(alias)) { // A manual target owns this alias — never clobber it. continue @@ -230,16 +239,6 @@ export function isRuntimeOwnedSshTarget(target: SshTarget): boolean { return target.owner?.type === 'on-demand-runtime' } -function isConfigManagedTarget(target: SshTarget): boolean { - // Why: a target is subject to config sync (and therefore needs a tombstone on - // delete) when it is explicitly config-sourced, or a legacy import that sync - // still adopts. Manual targets are excluded — sync never re-adds them. - return ( - target.source === 'ssh-config' || - (target.source === undefined && isLegacyConfigImportTarget(target)) - ) -} - function isLegacyConfigImportTarget(target: SshTarget): boolean { const alias = target.configHost ?? target.label // Why: legacy manual and imported targets both lack `source`. Only adopt the diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index bdb38f83466..f53190621ab 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -1998,6 +1998,30 @@ describe('SshConnection', () => { ) }) + it('tries GSSAPI first for a manually owned config-picker target', async () => { + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true }) + ) + const conn = new SshConnection( + createTarget({ + source: 'manual', + configHost: 'prod', + host: 'prod.internal', + gssapiAuthentication: true + }), + createCallbacks() + ) + + await conn.connect() + + expect(spawnSystemSshCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ source: 'manual', configHost: 'prod' }), + 'echo ORCA-SYSTEM-SSH-OK', + expect.objectContaining({ gssapiOnly: true, wrapCommand: false }) + ) + expect(clientInstances).toHaveLength(0) + }) + it('falls back to ssh2 when the GSSAPI-first system SSH attempt fails', async () => { spawnSystemSshCommandMock.mockImplementation(() => createFailingSystemCommandChannel(255, 'Permission denied (gssapi-with-mic,publickey)') diff --git a/src/main/ssh/ssh-g-config-resolution.test.ts b/src/main/ssh/ssh-g-config-resolution.test.ts new file mode 100644 index 00000000000..4f21c896376 --- /dev/null +++ b/src/main/ssh/ssh-g-config-resolution.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { existsSyncMock, homedirMock, userInfoMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn<(path: string) => boolean>(() => true), + homedirMock: vi.fn<() => string>(() => '/home/env'), + userInfoMock: vi.fn<() => { homedir: string }>(() => ({ homedir: '/home/env' })) +})) + +vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) +vi.mock('node:os', () => ({ homedir: homedirMock, userInfo: userInfoMock })) + +import { sshGArgsForHost } from './ssh-g-config-resolution' + +describe('sshGArgsForHost', () => { + beforeEach(() => { + existsSyncMock.mockReset().mockReturnValue(true) + homedirMock.mockReset().mockReturnValue('/home/env') + userInfoMock.mockReset().mockReturnValue({ homedir: '/home/env' }) + }) + + it('keeps OpenSSH default resolution when HOME matches the passwd home', () => { + // Why: the default search still reads /etc/ssh/ssh_config, which the + // SshResolvedConfig.gssapiAuthentication contract depends on. + expect(sshGArgsForHost('prod')).toEqual(['-G', '--', 'prod']) + }) + + it('pins the HOME config when it diverges from the passwd home', () => { + homedirMock.mockReturnValue('/tmp/e2e-home') + + expect(sshGArgsForHost('prod')).toEqual(['-F', '/tmp/e2e-home/.ssh/config', '-G', '--', 'prod']) + expect(existsSyncMock).toHaveBeenCalledWith('/tmp/e2e-home/.ssh/config') + }) + + it('falls back to passwd-home resolution when the HOME config is absent', () => { + // Why: ssh exits 255 on a missing -F file, so a divergent HOME without a + // config must not pin one. The picker lists nothing here, so the wider + // passwd-home resolution never mints a target. + homedirMock.mockReturnValue('/tmp/e2e-home') + existsSyncMock.mockReturnValue(false) + + expect(sshGArgsForHost('prod')).toEqual(['-G', '--', 'prod']) + }) + + it('treats an unavailable passwd entry as a HOME match', () => { + userInfoMock.mockImplementation(() => { + throw new Error('getpwuid failed') + }) + + expect(sshGArgsForHost('prod')).toEqual(['-G', '--', 'prod']) + }) + + it('does not let a leading-dash alias become a flag', () => { + homedirMock.mockReturnValue('/tmp/e2e-home') + + expect(sshGArgsForHost('-oProxyCommand=touch /tmp/pwned')).toEqual([ + '-F', + '/tmp/e2e-home/.ssh/config', + '-G', + '--', + '-oProxyCommand=touch /tmp/pwned' + ]) + }) +}) diff --git a/src/main/ssh/ssh-g-config-resolution.ts b/src/main/ssh/ssh-g-config-resolution.ts index 29c6d5eaae3..e329806ef3c 100644 --- a/src/main/ssh/ssh-g-config-resolution.ts +++ b/src/main/ssh/ssh-g-config-resolution.ts @@ -1,4 +1,7 @@ import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { homedir, userInfo } from 'node:os' import { resolveSshConfigHomePath } from './ssh-config-path-expansion' export type SshResolvedConfig = { @@ -9,7 +12,10 @@ export type SshResolvedConfig = { identityAgent?: string identitiesOnly: boolean forwardAgent: boolean - /** Effective GSSAPIAuthentication, including distro-wide /etc/ssh defaults. */ + /** + * Effective GSSAPIAuthentication, including distro-wide /etc/ssh defaults — except on the + * HOME-divergent `-F` path (see sshGArgsForHost), where OpenSSH skips the system config. + */ gssapiAuthentication?: boolean proxyCommand?: string proxyUseFdpass: boolean @@ -21,6 +27,31 @@ export type SshResolvedConfig = { const SSH_G_TIMEOUT_MS = 5000 +function passwdHomeSshConfigPath(): string { + try { + return join(userInfo().homedir, '.ssh', 'config') + } catch { + return join(homedir(), '.ssh', 'config') + } +} + +export function sshGArgsForHost(host: string): string[] { + // Why: OpenSSH resolves the default user config via getpwuid, not $HOME. + // Node's loadUserSshConfig uses os.homedir() (HOME-aware). When those differ + // (E2E HOME isolation, sandboxes), pass -F so resolve sees the same file the + // picker listed. Leave the default path alone when HOME matches passwd home + // so /etc/ssh/ssh_config still participates. + // -F also suppresses /etc/ssh/ssh_config, so that path resolves user-only. + // existsSync is load-bearing: ssh exits 255 on a missing -F file. Without a HOME + // config we fall back to passwd-home resolution, whose aliases the picker cannot + // list — resolveUserSshConfigHost rejects those before trusting ssh -G. + const homeConfigPath = join(homedir(), '.ssh', 'config') + if (existsSync(homeConfigPath) && homeConfigPath !== passwdHomeSshConfigPath()) { + return ['-F', homeConfigPath, '-G', '--', host] + } + return ['-G', '--', host] +} + // Why: `ssh -G ` asks OpenSSH for the effective config, including // Include/Match/wildcard inheritance, without reimplementing OpenSSH matching. export function resolveWithSshG(host: string): Promise { @@ -48,13 +79,18 @@ export function resolveWithSshG(host: string): Promise // Why: '--' prevents host labels starting with '-' from becoming SSH flags. // execFile's timeout only signals ssh; keep the null fallback for stuck callbacks. try { - child = execFile('ssh', ['-G', '--', host], { timeout: SSH_G_TIMEOUT_MS }, (err, stdout) => { - if (err) { - settle(() => resolve(null)) - return + child = execFile( + 'ssh', + sshGArgsForHost(host), + { timeout: SSH_G_TIMEOUT_MS }, + (err, stdout) => { + if (err) { + settle(() => resolve(null)) + return + } + settle(() => resolve(parseSshGOutput(stdout))) } - settle(() => resolve(parseSshGOutput(stdout))) - }) + ) } catch { settle(() => resolve(null)) } diff --git a/src/main/ssh/ssh-multi-key-authentication.test.ts b/src/main/ssh/ssh-multi-key-authentication.test.ts index 693e754b661..32942facd27 100644 --- a/src/main/ssh/ssh-multi-key-authentication.test.ts +++ b/src/main/ssh/ssh-multi-key-authentication.test.ts @@ -127,6 +127,29 @@ describe('ordered SSH private-key authentication', () => { expect(unresolvedImport.authHandler).toBeUndefined() }) + it('offers every resolved key for a manually owned config-picker target', () => { + const config = buildConnectConfig( + makeTarget({ + source: 'manual', + configHost: 'prod', + host: 'prod.internal', + identityFile: undefined + }), + makeResolved(), + { includeAgent: false, includePrivateKey: true } + ) + + expect(nextAuth(config, true)).toMatchObject({ type: 'none' }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'publickey', + key: Buffer.from('/keys/unauthorized-first') + }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'publickey', + key: Buffer.from('/keys/authorized-second') + }) + }) + it('resets ordered authentication for credential retries without extra key reads', () => { const config = buildConnectConfig(makeTarget(), makeResolved(), { includeAgent: false, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1fd19f8f77e..2acb211c3b0 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -54,6 +54,9 @@ import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pair import type { SshMutationExpectation, SshConnectionState, + SshConfigHostListArgs, + SshConfigHostListResult, + SshConfigHostResolution, SshConfigImportResult, SshTargetAddResult, SshTarget, @@ -3425,6 +3428,8 @@ export type PreloadApi = { }) => Promise removeTarget: (args: { id: string }) => Promise importConfig: (args?: { reAdopt?: boolean }) => Promise + listConfigHosts: (args?: SshConfigHostListArgs) => Promise + resolveConfigHost: (args: { alias: string }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise terminateSessions: (args: { targetId: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 329449c6ed6..c6bbf3739e1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -27,6 +27,9 @@ import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pair import type { SshMutationExpectation, SshConnectionState, + SshConfigHostListArgs, + SshConfigHostListResult, + SshConfigHostResolution, SshConfigImportResult, SshTargetAddResult, SshTarget, @@ -4412,6 +4415,12 @@ const api = { importConfig: (args?: { reAdopt?: boolean }): Promise => ipcRenderer.invoke('ssh:importConfig', args), + listConfigHosts: (args?: SshConfigHostListArgs): Promise => + ipcRenderer.invoke('ssh:listConfigHosts', args), + + resolveConfigHost: (args: { alias: string }): Promise => + ipcRenderer.invoke('ssh:resolveConfigHost', args), + connect: async (args: { targetId: string }): Promise => { const state: unknown = await ipcRenderer.invoke('ssh:connect', args) return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index e7001fcc783..ea41091f6a3 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -153,7 +153,7 @@ const devboxNeedsSetupHostOption: ProjectHostSetupOption = { projectId: 'project-group:platform', hostId: 'ssh:devbox', label: 'Devbox', - detail: 'Project not set up on this host', + detail: 'Project location not set', isAvailable: true, attention: false } @@ -581,7 +581,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { openRunTargetPicker(current.container) const devboxItem = findRunTargetItem('Devbox') - expect(devboxItem?.textContent).toContain('Project not set up on this host') + expect(devboxItem?.textContent).toContain('Project location not set') // Not-connected rows stay highlightable (never `disabled`) so they hover like // the other rows; they're quieted visually instead. expect(devboxItem?.hasAttribute('data-disabled')).toBe(false) diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx index 7f2edffe8eb..06b1267f80b 100644 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx @@ -61,7 +61,7 @@ const needsSetupOption: NeedsSetupProjectHostOption = { projectId: 'project-1', hostId: 'ssh:builder', label: 'Builder', - detail: 'Project not set up on this host', + detail: 'Project location not set', isAvailable: true, attention: false } @@ -129,7 +129,7 @@ describe('ProjectHostSetupCombobox', () => { expect( container.querySelector('[data-command-value="needs-setup:ssh:builder"]') ).toBeNull() - expect(container.textContent).not.toContain('Project not set up on this host') + expect(container.textContent).not.toContain('Project location not set') expect(onValueChange).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index 2008bd70154..f91d183bebb 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -80,6 +80,7 @@ export function RepositoryHostSetupsSection({ buildExecutionHostRegistry({ repos, settings, + hostSource: 'configured-only', sshTargetLabels, sshConnectionStates, runtimeEnvironments, diff --git a/src/renderer/src/components/settings/ssh-target-draft.test.ts b/src/renderer/src/components/settings/ssh-target-draft.test.ts index d393d9ffeab..9de02d2d35a 100644 --- a/src/renderer/src/components/settings/ssh-target-draft.test.ts +++ b/src/renderer/src/components/settings/ssh-target-draft.test.ts @@ -3,7 +3,9 @@ import { EMPTY_FORM, applyParsedSshHostInput, getEditingTargetForSshTarget, + getEditingTargetFromSshConfigHost, getSshTargetDraftConnectionFields, + hasAdvancedConnectionValues, isSshTargetFormDirty, parseRelayGracePeriodSeconds, parseSshHostInput, @@ -247,6 +249,63 @@ describe('getEditingTargetForSshTarget', () => { }) }) +describe('getEditingTargetFromSshConfigHost', () => { + it('prefills connection fields from a config summary', () => { + const draft = getEditingTargetFromSshConfigHost({ + alias: 'prod-bastion', + hostname: 'bastion.prod.example', + port: 2222, + username: 'ops', + identityFiles: ['~/.ssh/prod', '~/.ssh/fallback'], + identitiesOnly: true, + forwardAgent: false, + gssapiAuthentication: true, + proxyUseFdpass: false, + jumpHost: 'edge' + }) + + expect(draft).toMatchObject({ + label: 'prod-bastion', + configHost: 'prod-bastion', + host: 'bastion.prod.example', + port: '2222', + username: 'ops', + identityFile: '', + gssapiAuthentication: true, + jumpHost: 'edge' + }) + expect(getSshTargetDraftConnectionFields(draft)).toEqual({ + host: 'bastion.prod.example', + configHost: 'prod-bastion', + username: 'ops', + port: 2222 + }) + expect(hasAdvancedConnectionValues(draft)).toBe(true) + }) + + it('keeps alias as host when HostName is omitted', () => { + const draft = getEditingTargetFromSshConfigHost({ + alias: 'gpu-lab', + hostname: 'gpu-lab', + port: 22, + username: 'jinjing', + identityFiles: [], + identitiesOnly: false, + forwardAgent: false, + proxyUseFdpass: false + }) + + expect(draft.configHost).toBe('') + expect(draft.host).toBe('gpu-lab') + expect(getSshTargetDraftConnectionFields(draft)).toEqual({ + host: 'gpu-lab', + configHost: 'gpu-lab', + username: 'jinjing', + port: 22 + }) + }) +}) + describe('isSshTargetFormDirty', () => { it('is clean when the draft matches the baseline', () => { expect(isSshTargetFormDirty(EMPTY_FORM, EMPTY_FORM)).toBe(false) @@ -261,6 +320,7 @@ describe('isSshTargetFormDirty', () => { { port: '2222' }, { username: 'deploy' }, { identityFile: '~/.ssh/id_ed25519' }, + { gssapiAuthentication: true }, { proxyCommand: 'nc %h %p' }, { jumpHost: 'bastion' }, { systemSshConnectionReuse: false }, diff --git a/src/renderer/src/components/settings/ssh-target-draft.ts b/src/renderer/src/components/settings/ssh-target-draft.ts index 245a90eb0fa..a20541dfe60 100644 --- a/src/renderer/src/components/settings/ssh-target-draft.ts +++ b/src/renderer/src/components/settings/ssh-target-draft.ts @@ -3,6 +3,7 @@ import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, + type SshConfigHostResolution, type SshTarget } from '../../../../shared/ssh-types' @@ -13,6 +14,7 @@ export type EditingTarget = { port: string username: string identityFile: string + gssapiAuthentication: boolean proxyCommand: string jumpHost: string systemSshConnectionReuse: boolean @@ -27,6 +29,7 @@ export const EMPTY_FORM: EditingTarget = { port: '22', username: '', identityFile: '', + gssapiAuthentication: false, proxyCommand: '', jumpHost: '', systemSshConnectionReuse: true, @@ -45,6 +48,7 @@ export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget { port: String(target.port), username: target.username, identityFile: target.identityFile ?? '', + gssapiAuthentication: target.gssapiAuthentication === true, proxyCommand: target.proxyCommand ?? '', jumpHost: target.jumpHost ?? '', systemSshConnectionReuse: target.systemSshConnectionReuse !== false, @@ -58,6 +62,26 @@ export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget { } } +/** Prefill the add-host form from a ~/.ssh/config Host entry (does not save). */ +export function getEditingTargetFromSshConfigHost(host: SshConfigHostResolution): EditingTarget { + const configHost = host.alias !== host.hostname ? host.alias : '' + return { + ...EMPTY_FORM, + label: host.alias, + configHost, + // Why: keep the config alias in Host when there is no HostName so OpenSSH + // still resolves User/Port/Identity from ~/.ssh/config on connect. + host: host.hostname, + port: String(host.port), + username: host.username, + // Why: no scalar override lets connection-time ssh -G retain every IdentityFile. + identityFile: '', + gssapiAuthentication: host.gssapiAuthentication === true, + proxyCommand: host.proxyCommand ?? '', + jumpHost: host.jumpHost ?? '' + } +} + export type ParsedSshHostInput = { host: string username?: string @@ -153,6 +177,7 @@ export function isSshTargetFormDirty(current: EditingTarget, baseline: EditingTa current.port !== baseline.port || current.username !== baseline.username || current.identityFile !== baseline.identityFile || + current.gssapiAuthentication !== baseline.gssapiAuthentication || current.proxyCommand !== baseline.proxyCommand || current.jumpHost !== baseline.jumpHost || current.systemSshConnectionReuse !== baseline.systemSshConnectionReuse || diff --git a/src/renderer/src/components/settings/ssh-target-save-payload.ts b/src/renderer/src/components/settings/ssh-target-save-payload.ts index 89909f886f1..7133daf6db2 100644 --- a/src/renderer/src/components/settings/ssh-target-save-payload.ts +++ b/src/renderer/src/components/settings/ssh-target-save-payload.ts @@ -61,6 +61,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay host, port, username, + ...(form.gssapiAuthentication ? { gssapiAuthentication: true } : {}), relayGracePeriodSeconds: graceSeconds, ...(identityFile ? { identityFile } : {}), ...(proxyCommand ? { proxyCommand } : {}), @@ -77,6 +78,7 @@ export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePay // Why: updateTarget merges partially, so explicit undefined values are // required to clear optional fields inherited from ~/.ssh/config. identityFile, + gssapiAuthentication: form.gssapiAuthentication || undefined, proxyCommand, jumpHost, systemSshConnectionReuse, diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx new file mode 100644 index 00000000000..90886e07cc0 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx @@ -0,0 +1,211 @@ +// @vitest-environment happy-dom +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConfigHostResolution } from '../../../../shared/ssh-types' + +const toastMocks = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), toastMocks) })) + +const storeActions = vi.hoisted(() => ({ + setSshTargetsMetadata: vi.fn(), + recordSshRepoReadoptions: vi.fn(), + setRuntimeEnvironments: vi.fn(), + setRuntimeEnvironmentStatus: vi.fn(), + recordFeatureInteraction: vi.fn() +})) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => selector(storeActions) +})) + +import { AddRemoteHostDialog } from './AddRemoteHostDialog' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +function resolution(alias: string, hostname: string): SshConfigHostResolution { + return { + alias, + hostname, + port: 22, + username: 'deploy', + identityFiles: [], + identitiesOnly: false, + forwardAgent: false, + proxyUseFdpass: false + } +} + +const listConfigHosts = vi.fn() +const resolveConfigHost = vi.fn() +const importConfig = vi.fn() +const listTargets = vi.fn() + +function configHost( + alias: string, + overrides: Record = {} +): Record { + return { + alias, + hostname: `${alias}.internal`, + port: 22, + username: 'deploy', + alreadyInOrca: false, + ...overrides + } +} + +beforeEach(() => { + vi.clearAllMocks() + listConfigHosts.mockResolvedValue({ + hosts: [configHost('alpha'), configHost('bravo')], + totalHostCount: 2, + newHostCount: 2, + matchCount: 2, + hasMore: false + }) + importConfig.mockResolvedValue({ targets: [], repoReadoptions: [] }) + listTargets.mockResolvedValue([]) + Object.defineProperty(window, 'api', { + configurable: true, + value: { ssh: { listConfigHosts, resolveConfigHost, importConfig, listTargets } } + }) +}) + +afterEach(() => { + cleanup() +}) + +async function openPicker(): Promise> { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /Fill from/ })) + await screen.findByRole('button', { name: /alpha/ }) + return user +} + +async function openPickerWith(result: Record): Promise { + listConfigHosts.mockResolvedValue({ hasMore: false, ...result }) + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /Fill from/ })) +} + +describe('SSH config picker host selection', () => { + it('drops a resolve that lands after the user backs out of the picker', async () => { + const pending = deferred() + resolveConfigHost.mockReturnValueOnce(pending.promise) + const user = await openPicker() + + await user.click(screen.getByRole('button', { name: /alpha/ })) + await user.click(screen.getByRole('button', { name: 'Back' })) + pending.resolve(resolution('alpha', 'alpha.internal')) + await waitFor(() => expect(screen.getByLabelText('Host or alias')).toBeDefined()) + + expect((screen.getByLabelText('Host or alias') as HTMLInputElement).value).toBe('') + expect(toastMocks.success).not.toHaveBeenCalled() + }) + + it('keeps the host the user settled on when an earlier resolve lands late', async () => { + const slowAlpha = deferred() + resolveConfigHost.mockReturnValueOnce(slowAlpha.promise) + resolveConfigHost.mockResolvedValueOnce(resolution('bravo', 'bravo.internal')) + const user = await openPicker() + + await user.click(screen.getByRole('button', { name: /alpha/ })) + await user.click(screen.getByRole('button', { name: 'Back' })) + await user.click(screen.getByRole('button', { name: /Fill from/ })) + await user.click(await screen.findByRole('button', { name: /bravo/ })) + await waitFor(() => + expect((screen.getByLabelText('Host or alias') as HTMLInputElement).value).toBe( + 'bravo.internal' + ) + ) + + await act(async () => { + slowAlpha.resolve(resolution('alpha', 'alpha.internal')) + await slowAlpha.promise + }) + + expect((screen.getByLabelText('Host or alias') as HTMLInputElement).value).toBe( + 'bravo.internal' + ) + }) + + it('freezes the other rows while a pick is resolving', async () => { + const pending = deferred() + resolveConfigHost.mockReturnValueOnce(pending.promise) + const user = await openPicker() + + await user.click(screen.getByRole('button', { name: /alpha/ })) + + await waitFor(() => + expect(screen.getByRole('button', { name: /bravo/ }).hasAttribute('disabled')).toBe(true) + ) + await act(async () => { + pending.resolve(resolution('alpha', 'alpha.internal')) + await pending.promise + }) + }) +}) + +describe('SSH config picker tombstoned hosts', () => { + it('keeps a previously removed host pickable while an adopted one stays disabled', async () => { + await openPickerWith({ + hosts: [ + configHost('removed', { previouslyRemoved: true }), + configHost('kept', { alreadyInOrca: true }) + ], + totalHostCount: 2, + newHostCount: 0, + matchCount: 2 + }) + + const removed = await screen.findByRole('button', { name: /removed/ }) + expect(removed.hasAttribute('disabled')).toBe(false) + expect(removed.textContent).toContain('Removed from Orca') + + const kept = screen.getByRole('button', { name: /kept/ }) + expect(kept.hasAttribute('disabled')).toBe(true) + expect(kept.textContent).toContain('In Orca') + }) + + it('never claims the config is empty when every host is only tombstoned', async () => { + await openPickerWith({ + hosts: [configHost('removed', { previouslyRemoved: true })], + totalHostCount: 1, + newHostCount: 0, + matchCount: 1 + }) + + const addAll = await screen.findByRole('button', { name: 'No new hosts to add' }) + expect(addAll.hasAttribute('disabled')).toBe(true) + expect(screen.queryByText('No hosts in ~/.ssh/config')).toBeNull() + }) + + it('still shows the empty state when the config really has no hosts', async () => { + await openPickerWith({ hosts: [], totalHostCount: 0, newHostCount: 0, matchCount: 0 }) + + expect(await screen.findByText('No hosts in ~/.ssh/config')).toBeDefined() + expect(screen.getByRole('button', { name: 'Add all to Orca' }).hasAttribute('disabled')).toBe( + true + ) + }) +}) + +describe('SSH config picker bulk add', () => { + it('adds only new hosts and never re-adopts deleted aliases', async () => { + const user = await openPicker() + + await user.click(screen.getByRole('button', { name: /Add all 2 to Orca/ })) + + await waitFor(() => expect(importConfig).toHaveBeenCalled()) + expect(importConfig).toHaveBeenCalledWith() + expect(importConfig.mock.calls[0][0]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx index 5e2527bf0d6..03658792d94 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx @@ -1,30 +1,24 @@ -import { useMemo, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' +import { Dialog, DialogContent } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import { - EMPTY_FORM, - getSshTargetDraftConnectionFields, - isRelayGracePeriodValid, - parseRelayGracePeriodSeconds, - type EditingTarget -} from '../settings/ssh-target-draft' -import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../../../shared/ssh-types' +import { EMPTY_FORM, type EditingTarget } from '../settings/ssh-target-draft' +import type { SshConfigHostSummary } from '../../../../shared/ssh-types' import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' import { translateHostAccessLinkError, translateRemotePairingFailureDescription } from '@/lib/remote-pairing-copy' -import { RemoteServerFields, SshHostFields } from './AddRemoteHostFields' +import { AddRemoteHostSshConfigPicker } from './AddRemoteHostSshConfigPicker' +import { AddRemoteHostSshFormPanel } from './AddRemoteHostSshFormPanel' +import { AddRemoteHostServerFormPanel } from './AddRemoteHostServerFormPanel' +import { + addAllSshConfigHostsToOrca, + loadSshConfigHostsForPicker, + prefillFormFromSshConfigHost, + saveNewSshHostFromForm +} from './add-remote-host-ssh-actions' export type AddRemoteHostMode = 'ssh' | 'server' @@ -33,6 +27,8 @@ type AddRemoteHostDialogProps = { onOpenChange: (mode: AddRemoteHostMode | null) => void } +type SshDialogView = 'form' | 'config-picker' + export function AddRemoteHostDialog({ mode, onOpenChange @@ -46,11 +42,24 @@ export function AddRemoteHostDialog({ setRenderMode(mode) } const [sshForm, setSshForm] = useState(EMPTY_FORM) + const [sshView, setSshView] = useState('form') + const [configHosts, setConfigHosts] = useState([]) + const [configHostCount, setConfigHostCount] = useState(0) + const [newConfigHostCount, setNewConfigHostCount] = useState(0) + const [configHostMatchesTruncated, setConfigHostMatchesTruncated] = useState(false) + const [isLoadingConfigHosts, setIsLoadingConfigHosts] = useState(false) + const [resolvingConfigAlias, setResolvingConfigAlias] = useState(null) + const [isBulkImporting, setIsBulkImporting] = useState(false) + const [configHostsError, setConfigHostsError] = useState(null) + const [preferAdvancedOpen, setPreferAdvancedOpen] = useState(false) + const [configFilledAlias, setConfigFilledAlias] = useState(null) const [serverName, setServerName] = useState('') const [pairingCode, setPairingCode] = useState('') const [allowLoopback, setAllowLoopback] = useState(false) const [isSaving, setIsSaving] = useState(false) - const [isImporting, setIsImporting] = useState(false) + const configSearchGeneration = useRef(0) + const configSearchQuery = useRef('') + const configResolveGeneration = useRef(0) const parsedServerLink = useMemo(() => parseHostAccessLink(pairingCode), [pairingCode]) const serverFormCanSubmit = serverName.trim() !== '' && @@ -62,136 +71,169 @@ export function AddRemoteHostDialog({ const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) - const close = () => { - if (isSaving || isImporting) { - return - } - onOpenChange(null) + const busy = isSaving || isBulkImporting || resolvingConfigAlias !== null + + // Why: a pending resolve must never write into a form the user has moved on from. + const invalidatePendingConfigResolve = () => { + configResolveGeneration.current += 1 + setResolvingConfigAlias(null) } const reset = () => { setSshForm(EMPTY_FORM) + setSshView('form') + setConfigHosts([]) + setConfigHostCount(0) + setNewConfigHostCount(0) + setConfigHostMatchesTruncated(false) + setConfigHostsError(null) + configSearchQuery.current = '' + invalidatePendingConfigResolve() + setPreferAdvancedOpen(false) + setConfigFilledAlias(null) + setIsBulkImporting(false) setServerName('') setPairingCode('') setAllowLoopback(false) } - const refreshSshTargetMetadata = async () => { - const targets = (await window.api.ssh.listTargets()) as SshTarget[] - setSshTargetsMetadata(targets) + const close = () => { + // Why: a stuck resolve must not trap the dialog open — reset() invalidates it instead. + if (isSaving || isBulkImporting) { + return + } + reset() + onOpenChange(null) } const saveSshHost = async () => { - const { host, configHost, username, port } = getSshTargetDraftConnectionFields(sshForm) - if (!host) { - toast.error( - translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshHostRequired', - 'Host or SSH config alias is required.' - ) - ) - return - } - if (Number.isNaN(port) || port < 1 || port > 65535) { - toast.error( - translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshPortInvalid', - 'Port must be between 1 and 65535.' - ) - ) - return - } - const graceSeconds = parseRelayGracePeriodSeconds(sshForm) - if (!isRelayGracePeriodValid(sshForm, graceSeconds)) { - toast.error( - translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshRelayGraceInvalid', - 'Terminal timeout must be between 60 and {{value0}} seconds.', - { value0: MAX_SSH_RELAY_GRACE_PERIOD_SECONDS } - ) - ) - return - } - - const identityFile = sshForm.identityFile.trim() || undefined - const proxyCommand = sshForm.proxyCommand.trim() || undefined - const jumpHost = sshForm.jumpHost.trim() || undefined - const systemSshConnectionReuse = sshForm.systemSshConnectionReuse ? undefined : false - const target = { - label: sshForm.label.trim() || (username ? `${username}@${host}` : configHost), - configHost, - host, - port, - username, - relayGracePeriodSeconds: graceSeconds, - ...(identityFile ? { identityFile } : {}), - ...(proxyCommand ? { proxyCommand } : {}), - ...(jumpHost ? { jumpHost } : {}), - ...(systemSshConnectionReuse === false ? { systemSshConnectionReuse } : {}) - } - setIsSaving(true) try { - const result = await window.api.ssh.addTarget({ target }) - recordSshRepoReadoptions(result.repoReadoptions) - await refreshSshTargetMetadata() - recordFeatureInteraction('ssh') - toast.success( - translate('auto.components.sidebar.AddRemoteHostDialog.sshSaved', 'SSH host added.') - ) - reset() - onOpenChange(null) - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshSaveFailed', - 'Failed to add SSH host.' - ) - ) + const outcome = await saveNewSshHostFromForm({ + form: sshForm, + ssh: window.api.ssh, + recordSshRepoReadoptions, + setSshTargetsMetadata, + recordFeatureInteraction + }) + if (outcome === 'saved') { + reset() + onOpenChange(null) + } } finally { setIsSaving(false) } } - const importSshConfig = async () => { - setIsImporting(true) + const loadSshConfigHosts = async (query = '', options?: { refresh?: boolean }) => { + configSearchQuery.current = query + const generation = configSearchGeneration.current + 1 + configSearchGeneration.current = generation + setIsLoadingConfigHosts(true) + setConfigHostsError(null) + const result = await loadSshConfigHostsForPicker(window.api.ssh, { + query, + ...(options?.refresh ? { refresh: true } : {}) + }) + if (generation !== configSearchGeneration.current) { + return + } + if (result.ok) { + setConfigHosts(result.result.hosts) + setConfigHostCount(result.result.totalHostCount) + setNewConfigHostCount(result.result.newHostCount) + setConfigHostMatchesTruncated(result.result.hasMore) + } else { + setConfigHosts([]) + setConfigHostsError(result.error) + } + setIsLoadingConfigHosts(false) + } + + const openSshConfigPicker = async () => { + setSshView('config-picker') + // Why: re-read ~/.ssh/config on open; the filter keystrokes reuse that parse. + await loadSshConfigHosts('', { refresh: true }) + } + + const leaveSshConfigPicker = () => { + invalidatePendingConfigResolve() + setSshView('form') + } + + const selectSshConfigHost = async (host: SshConfigHostSummary) => { + const generation = configResolveGeneration.current + 1 + configResolveGeneration.current = generation + setResolvingConfigAlias(host.alias) + // Why: a slower earlier pick must not overwrite the host the user settled on. + const isStale = () => generation !== configResolveGeneration.current + let resolved: Awaited> try { - const result = await window.api.ssh.importConfig() - const synced = result.targets - recordSshRepoReadoptions(result.repoReadoptions) - await refreshSshTargetMetadata() - recordFeatureInteraction('ssh') - if (synced.length === 0) { - toast( - translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshImportAlreadySynced', - '~/.ssh/config already in sync.' - ) - ) - } else { - toast.success( - translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshImportSynced', - 'Synced {{value0}} host{{value1}}.', - { value0: synced.length, value1: synced.length > 1 ? 's' : '' } - ) - ) - reset() - onOpenChange(null) - } + resolved = await prefillFormFromSshConfigHost(host, window.api.ssh) } catch (error) { + if (isStale()) { + return + } + setResolvingConfigAlias(null) toast.error( error instanceof Error ? error.message : translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshImportFailed', - 'Failed to import SSH config.' + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerResolveFailed', + 'Failed to resolve that SSH config host.' ) ) + return + } + if (isStale()) { + return + } + setResolvingConfigAlias(null) + if (!resolved) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerResolveFailed', + 'Failed to resolve that SSH config host.' + ) + ) + return + } + const { form, preferAdvancedOpen: openAdvanced } = resolved + setSshForm(form) + setPreferAdvancedOpen(openAdvanced) + setConfigFilledAlias(host.alias) + setSshView('form') + recordFeatureInteraction('ssh') + toast.success( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerFilled', + 'Filled from {{value0}}. Review and Save.', + { value0: host.alias } + ) + ) + } + + const addAllConfigHostsToOrca = async () => { + setIsBulkImporting(true) + try { + const result = await addAllSshConfigHostsToOrca({ + ssh: window.api.ssh, + recordSshRepoReadoptions, + setSshTargetsMetadata, + recordFeatureInteraction + }) + if (result.kind === 'added') { + reset() + onOpenChange(null) + return + } + if (result.kind === 'already-synced') { + // Why: reuse the loader so the refresh keeps the active filter and stays inside the + // generation guard against an in-flight debounced search. + await loadSshConfigHosts(configSearchQuery.current) + } } finally { - setIsImporting(false) + setIsBulkImporting(false) } } @@ -264,6 +306,8 @@ export function AddRemoteHostDialog({ } } + const showSshConfigPicker = renderMode === 'ssh' && sshView === 'config-picker' + return ( - - - - {renderMode === 'server' - ? translate( - 'auto.components.sidebar.AddRemoteHostDialog.serverTitle', - 'Add remote server' - ) - : translate('auto.components.sidebar.AddRemoteHostDialog.sshTitle', 'Add SSH host')} - - - {renderMode === 'server' - ? translate( - 'auto.components.sidebar.AddRemoteHostDialog.serverDescription', - 'Pair with Orca running on another computer.' - ) - : translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshDescription', - 'Add a persistent machine you can log into over SSH.' - )} - - - - {renderMode === 'server' ? ( - + {showSshConfigPicker ? ( +
+ void selectSshConfigHost(host)} + onQueryChange={(query) => void loadSshConfigHosts(query)} + onRetry={() => void loadSshConfigHosts(configSearchQuery.current, { refresh: true })} + onBack={leaveSshConfigPicker} + onAddAllToOrca={() => void addAllConfigHostsToOrca()} + /> +
+ ) : renderMode === 'ssh' ? ( + void saveSshHost()} + onCancel={close} + onFillFromConfig={() => void openSshConfigPicker()} + /> + ) : ( + { setPairingCode(value) setAllowLoopback(false) }} - allowLoopback={allowLoopback} onAllowLoopbackChange={setAllowLoopback} onSubmit={() => void saveRemoteServer()} - /> - ) : ( - void saveSshHost()} + onCancel={close} /> )} - - - {renderMode === 'ssh' ? ( - - ) : ( - - )} -
- - -
-
) diff --git a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx index 60a940dd0e0..cdab890d459 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx @@ -15,15 +15,21 @@ import { SshHostAdvancedFields } from '../settings/SshHostAdvancedFields' export function SshHostFields({ form, disabled, + preferAdvancedOpen = false, + configIdentityAlias = null, onFormChange, onSubmit }: { form: EditingTarget disabled: boolean + /** When true after a config pick, expand Advanced so proxy/jump stay visible. */ + preferAdvancedOpen?: boolean + /** Alias this form was filled from, so an empty Identity file can be explained. */ + configIdentityAlias?: string | null onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void onSubmit: () => void }) { - const [advancedOpen, setAdvancedOpen] = useState(false) + const [advancedOpen, setAdvancedOpen] = useState(preferAdvancedOpen) return (
+ {configIdentityAlias && form.identityFile.trim() === '' ? ( +

+ {translate( + 'auto.components.sidebar.AddRemoteHostDialog.identityFileFromConfigHint', + 'Left empty on purpose: Orca uses every key ~/.ssh/config resolves for {{value0}}. Type a path to use just that key.', + { value0: configIdentityAlias } + )} +

+ ) : null} void + onPairingCodeChange: (value: string) => void + onAllowLoopbackChange: (value: boolean) => void + onSubmit: () => void + onCancel: () => void +}): React.JSX.Element { + return ( + <> + + + {translate( + 'auto.components.sidebar.AddRemoteHostDialog.serverTitle', + 'Add remote server' + )} + + + {translate( + 'auto.components.sidebar.AddRemoteHostDialog.serverDescription', + 'Pair with Orca running on another computer.' + )} + + + + + + + +
+ + +
+
+ + ) +} diff --git a/src/renderer/src/components/sidebar/AddRemoteHostSshConfigPicker.tsx b/src/renderer/src/components/sidebar/AddRemoteHostSshConfigPicker.tsx new file mode 100644 index 00000000000..964fa2357f3 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRemoteHostSshConfigPicker.tsx @@ -0,0 +1,276 @@ +import { useEffect, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { + SSH_CONFIG_HOST_RESULT_LIMIT, + type SshConfigHostSummary +} from '../../../../shared/ssh-types' +import { cn } from '@/lib/utils' + +const EMPTY_CONFIG_HOSTS: SshConfigHostSummary[] = [] + +export function AddRemoteHostSshConfigPicker({ + hosts = EMPTY_CONFIG_HOSTS, + totalHostCount = 0, + newHostCount = 0, + matchesTruncated = false, + isLoading, + isBulkImporting, + resolvingAlias = null, + loadError, + onSelect, + onQueryChange, + onRetry, + onBack, + onAddAllToOrca +}: { + hosts: SshConfigHostSummary[] + totalHostCount: number + newHostCount: number + matchesTruncated: boolean + isLoading: boolean + isBulkImporting: boolean + /** Alias whose `ssh -G` resolve is in flight; picking is frozen until it settles. */ + resolvingAlias: string | null + loadError: string | null + onSelect: (host: SshConfigHostSummary) => void + onQueryChange: (query: string) => void + onRetry: () => void + onBack: () => void + onAddAllToOrca: () => void +}): React.JSX.Element { + const [query, setQuery] = useState('') + const queryTimer = useRef | null>(null) + useEffect( + () => () => { + if (queryTimer.current) { + clearTimeout(queryTimer.current) + } + }, + [] + ) + const isResolving = resolvingAlias != null + // Why: filtering re-runs against a cached parse, so keep the field usable while a + // refresh (or a failed load the user can retry) is outstanding. + const filterDisabled = isBulkImporting || isResolving + const picksDisabled = isBulkImporting || isResolving + const canAddAll = + !isLoading && !isBulkImporting && !isResolving && loadError == null && newHostCount > 0 + + return ( +
+ + + {translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerTitle', + 'Choose from ~/.ssh/config' + )} + + + {translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerDescription', + 'Pick a host to fill the form, or add every new host to Orca’s host list.' + )} + + + + { + const value = event.target.value + setQuery(value) + if (queryTimer.current) { + clearTimeout(queryTimer.current) + } + queryTimer.current = setTimeout(() => onQueryChange(value), 200) + }} + placeholder={translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerFilter', + 'Filter hosts…' + )} + autoFocus + disabled={filterDisabled} + aria-label={translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerFilter', + 'Filter hosts…' + )} + /> + +
+ {loadError != null ? ( +
+

{loadError}

+ +
+ ) : isLoading && hosts.length === 0 ? ( +

+ {translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerLoading', + 'Reading ~/.ssh/config…' + )} +

+ ) : hosts.length === 0 ? ( +
+

+ {totalHostCount === 0 + ? translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerEmpty', + 'No hosts in ~/.ssh/config' + ) + : translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerNoMatch', + 'No matching hosts' + )} +

+

+ {totalHostCount === 0 + ? translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerEmptyHint', + 'Add a Host entry there, or go back and type the details manually.' + ) + : translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerNoMatchHint', + 'Try another filter, or go back and type manually.' + )} +

+
+ ) : ( + // Why: these rows act on click and there is no selected row to track, so they stay + // plain buttons rather than a listbox whose aria-selected would always be a lie. +
    + {hosts.map((host) => ( +
  • + +
  • + ))} +
+ )} +
+ + {matchesTruncated ? ( +

+ {translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerMoreResults', + 'Showing the first {{value0}} matches. Narrow your filter to find more.', + { value0: SSH_CONFIG_HOST_RESULT_LIMIT } + )} +

+ ) : null} + +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/AddRemoteHostSshFormPanel.tsx b/src/renderer/src/components/sidebar/AddRemoteHostSshFormPanel.tsx new file mode 100644 index 00000000000..fe93ba105ae --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRemoteHostSshFormPanel.tsx @@ -0,0 +1,75 @@ +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { EditingTarget } from '../settings/ssh-target-draft' +import { SshHostFields } from './AddRemoteHostFields' + +export function AddRemoteHostSshFormPanel({ + form, + disabled, + preferAdvancedOpen, + configIdentityAlias, + onFormChange, + onSubmit, + onCancel, + onFillFromConfig +}: { + form: EditingTarget + disabled: boolean + preferAdvancedOpen: boolean + configIdentityAlias: string | null + onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void + onSubmit: () => void + onCancel: () => void + onFillFromConfig: () => void +}): React.JSX.Element { + return ( + <> + + + {translate('auto.components.sidebar.AddRemoteHostDialog.sshTitle', 'Add SSH host')} + + + {translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshDescription', + 'Add a persistent machine you can log into over SSH.' + )} + + + + + + + +
+ + +
+
+ + ) +} diff --git a/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts new file mode 100644 index 00000000000..3ab43cd5488 --- /dev/null +++ b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshTarget } from '../../../../shared/ssh-types' +import { EMPTY_FORM } from '../settings/ssh-target-draft' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), toastMocks) +})) + +import { + addAllSshConfigHostsToOrca, + loadSshConfigHostsForPicker, + prefillFormFromSshConfigHost, + saveNewSshHostFromForm +} from './add-remote-host-ssh-actions' + +describe('individual SSH config host selection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('saves effective values while leaving all config identities authoritative', async () => { + let savedTarget: Omit | undefined + const ssh = { + resolveConfigHost: vi.fn().mockResolvedValue({ + alias: 'prod', + hostname: 'prod.internal', + port: 2222, + username: 'deploy', + identityFiles: ['/keys/first', '/keys/second'], + identitiesOnly: true, + forwardAgent: false, + gssapiAuthentication: true, + proxyUseFdpass: false + }), + listTargets: vi + .fn() + .mockImplementation(async () => + savedTarget ? [{ ...savedTarget, id: 'ssh-prod', source: 'manual' as const }] : [] + ), + addTarget: vi.fn().mockImplementation(async ({ target }) => { + savedTarget = target + return { target: { ...target, id: 'ssh-prod', source: 'manual' }, repoReadoptions: [] } + }), + listConfigHosts: vi.fn(), + importConfig: vi.fn() + } + const selection = await prefillFormFromSshConfigHost( + { + alias: 'prod', + hostname: '%h.internal', + port: 22, + username: '', + identityFile: '/keys/second', + alreadyInOrca: false + }, + ssh + ) + + expect(selection).not.toBeNull() + const outcome = await saveNewSshHostFromForm({ + form: selection!.form, + ssh, + recordSshRepoReadoptions: vi.fn(), + setSshTargetsMetadata: vi.fn(), + recordFeatureInteraction: vi.fn() + }) + + expect(outcome).toBe('saved') + expect(ssh.resolveConfigHost).toHaveBeenCalledWith({ alias: 'prod' }) + expect(savedTarget).toMatchObject({ + label: 'prod', + configHost: 'prod', + host: 'prod.internal', + port: 2222, + username: 'deploy', + gssapiAuthentication: true + }) + expect(savedTarget).not.toHaveProperty('identityFile') + expect(savedTarget).not.toHaveProperty('source') + }) +}) + +describe('manual SSH host label fallback', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('labels a bare host with its hostname instead of an empty string', async () => { + let savedTarget: Omit | undefined + const ssh = { + resolveConfigHost: vi.fn(), + listTargets: vi.fn().mockResolvedValue([]), + addTarget: vi.fn().mockImplementation(async ({ target }) => { + savedTarget = target + return { target: { ...target, id: 'ssh-1', source: 'manual' }, repoReadoptions: [] } + }), + listConfigHosts: vi.fn(), + importConfig: vi.fn() + } + + const outcome = await saveNewSshHostFromForm({ + form: { ...EMPTY_FORM, host: '10.0.0.7' }, + ssh, + recordSshRepoReadoptions: vi.fn(), + setSshTargetsMetadata: vi.fn(), + recordFeatureInteraction: vi.fn() + }) + + expect(outcome).toBe('saved') + expect(savedTarget?.label).toBe('10.0.0.7') + }) +}) + +describe('bulk add of ~/.ssh/config hosts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The button counts (and promises) only the new hosts the picker listed; re-adopting + // would resurrect hosts the user deleted, which those counts deliberately omit. + it('imports without re-adopting deleted aliases', async () => { + const importConfig = vi.fn().mockResolvedValue({ + targets: [{ id: 'ssh-1', label: 'prod', host: 'prod', port: 22, username: '' }], + repoReadoptions: [] + }) + const ssh = { + importConfig, + listTargets: vi.fn().mockResolvedValue([]), + addTarget: vi.fn(), + listConfigHosts: vi.fn(), + resolveConfigHost: vi.fn() + } + + const result = await addAllSshConfigHostsToOrca({ + ssh, + recordSshRepoReadoptions: vi.fn(), + setSshTargetsMetadata: vi.fn(), + recordFeatureInteraction: vi.fn() + }) + + expect(result).toEqual({ kind: 'added', count: 1 }) + expect(importConfig).toHaveBeenCalledTimes(1) + expect(importConfig.mock.calls[0][0]).toBeUndefined() + }) + + it('reports already-synced without clearing tombstones', async () => { + const importConfig = vi.fn().mockResolvedValue({ targets: [], repoReadoptions: [] }) + const result = await addAllSshConfigHostsToOrca({ + ssh: { + importConfig, + listTargets: vi.fn().mockResolvedValue([]), + addTarget: vi.fn(), + listConfigHosts: vi.fn(), + resolveConfigHost: vi.fn() + }, + recordSshRepoReadoptions: vi.fn(), + setSshTargetsMetadata: vi.fn(), + recordFeatureInteraction: vi.fn() + }) + + expect(result).toEqual({ kind: 'already-synced' }) + expect(importConfig.mock.calls[0][0]).toBeUndefined() + }) +}) + +describe('SSH config picker response admission', () => { + it('accepts the legacy preload array without exposing an unbounded row list', async () => { + const hosts = Array.from({ length: 150 }, (_, index) => ({ + alias: `host-${index}`, + hostname: `host-${index}`, + port: 22, + username: '', + alreadyInOrca: index === 0 + })) + const result = await loadSshConfigHostsForPicker({ + listConfigHosts: vi.fn().mockResolvedValue(hosts) + } as never) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.result).toMatchObject({ + totalHostCount: 150, + newHostCount: 149, + matchCount: 150, + hasMore: true + }) + expect(result.result.hosts).toHaveLength(100) + } + }) + + it('explains when a live renderer still has the older preload API', async () => { + await expect( + prefillFormFromSshConfigHost( + { + alias: 'prod', + hostname: 'prod', + port: 22, + username: '', + alreadyInOrca: false + }, + {} as never + ) + ).rejects.toThrow('Restart Orca') + }) +}) diff --git a/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts new file mode 100644 index 00000000000..7ad8c6f5f86 --- /dev/null +++ b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts @@ -0,0 +1,266 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { + getEditingTargetFromSshConfigHost, + getSshTargetDraftConnectionFields, + hasAdvancedConnectionValues, + isRelayGracePeriodValid, + parseRelayGracePeriodSeconds, + type EditingTarget +} from '../settings/ssh-target-draft' +import { + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, + SSH_CONFIG_HOST_RESULT_LIMIT +} from '../../../../shared/ssh-types' +import type { + SshConfigHostSummary, + SshConfigHostListArgs, + SshConfigHostListResult, + SshConfigHostResolution, + SshRepoReadoption, + SshTarget, + SshTargetAddResult +} from '../../../../shared/ssh-types' +import { isDuplicateSshTargetAlias } from './ssh-target-duplicate' + +type SshApi = { + listTargets: () => Promise + addTarget: (args: { target: Omit }) => Promise + listConfigHosts: (args?: SshConfigHostListArgs) => Promise + resolveConfigHost: (args: { alias: string }) => Promise + importConfig: (args?: { reAdopt?: boolean }) => Promise<{ + targets: SshTarget[] + repoReadoptions: SshRepoReadoption[] + }> +} + +export async function saveNewSshHostFromForm({ + form, + ssh, + recordSshRepoReadoptions, + setSshTargetsMetadata, + recordFeatureInteraction +}: { + form: EditingTarget + ssh: SshApi + recordSshRepoReadoptions: (readoptions: SshRepoReadoption[]) => void + setSshTargetsMetadata: (targets: SshTarget[]) => void + recordFeatureInteraction: (feature: 'ssh') => void +}): Promise<'saved' | 'validation-failed' | 'failed'> { + const { host, configHost, username, port } = getSshTargetDraftConnectionFields(form) + if (!host) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshHostRequired', + 'Host or SSH config alias is required.' + ) + ) + return 'validation-failed' + } + if (Number.isNaN(port) || port < 1 || port > 65535) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshPortInvalid', + 'Port must be between 1 and 65535.' + ) + ) + return 'validation-failed' + } + const graceSeconds = parseRelayGracePeriodSeconds(form) + if (!isRelayGracePeriodValid(form, graceSeconds)) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshRelayGraceInvalid', + 'Terminal timeout must be between 60 and {{value0}} seconds.', + { value0: MAX_SSH_RELAY_GRACE_PERIOD_SECONDS } + ) + ) + return 'validation-failed' + } + + const identityFile = form.identityFile.trim() || undefined + const proxyCommand = form.proxyCommand.trim() || undefined + const jumpHost = form.jumpHost.trim() || undefined + const systemSshConnectionReuse = form.systemSshConnectionReuse ? undefined : false + const target = { + label: form.label.trim() || (username ? `${username}@${host}` : configHost || host), + configHost, + host, + port, + username, + ...(form.gssapiAuthentication ? { gssapiAuthentication: true } : {}), + relayGracePeriodSeconds: graceSeconds, + ...(identityFile ? { identityFile } : {}), + ...(proxyCommand ? { proxyCommand } : {}), + ...(jumpHost ? { jumpHost } : {}), + ...(systemSshConnectionReuse === false ? { systemSshConnectionReuse } : {}) + } + + try { + const existingTargets = await ssh.listTargets() + if ( + isDuplicateSshTargetAlias({ + existingTargets, + configHost: target.configHost, + label: target.label, + host: target.host + }) + ) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshAlreadyExists', + 'That SSH host is already in Orca.' + ) + ) + return 'validation-failed' + } + + const result = await ssh.addTarget({ target }) + recordSshRepoReadoptions(result.repoReadoptions) + setSshTargetsMetadata(await ssh.listTargets()) + recordFeatureInteraction('ssh') + toast.success( + translate('auto.components.sidebar.AddRemoteHostDialog.sshSaved', 'SSH host added.') + ) + return 'saved' + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshSaveFailed', + 'Failed to add SSH host.' + ) + ) + return 'failed' + } +} + +export async function prefillFormFromSshConfigHost( + host: SshConfigHostSummary, + ssh: Pick +): Promise<{ + form: EditingTarget + preferAdvancedOpen: boolean +} | null> { + if (typeof ssh.resolveConfigHost !== 'function') { + throw new Error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerRestartRequired', + 'Restart Orca to finish applying the SSH config picker update.' + ) + ) + } + const resolved = await ssh.resolveConfigHost({ alias: host.alias }) + if (!resolved) { + return null + } + const form = getEditingTargetFromSshConfigHost(resolved) + return { + form, + preferAdvancedOpen: hasAdvancedConnectionValues(form) + } +} + +/** Bulk-load ~/.ssh/config hosts into Orca’s host list (sidebar targets). */ +export async function addAllSshConfigHostsToOrca({ + ssh, + recordSshRepoReadoptions, + setSshTargetsMetadata, + recordFeatureInteraction +}: { + ssh: SshApi + recordSshRepoReadoptions: (readoptions: SshRepoReadoption[]) => void + setSshTargetsMetadata: (targets: SshTarget[]) => void + recordFeatureInteraction: (feature: 'ssh') => void +}): Promise<{ kind: 'added'; count: number } | { kind: 'already-synced' } | { kind: 'failed' }> { + try { + // Why: no reAdopt — the button counts and promises only the *new* hosts the picker + // showed. Re-adopting would resurrect hosts the user deleted, which the count omits. + // Settings → Import stays the explicit re-adopt path. + const result = await ssh.importConfig() + recordSshRepoReadoptions(result.repoReadoptions) + setSshTargetsMetadata(await ssh.listTargets()) + recordFeatureInteraction('ssh') + if (result.targets.length === 0) { + toast( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshImportAlreadySynced', + '~/.ssh/config already in sync.' + ) + ) + return { kind: 'already-synced' } + } + toast.success( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshImportSynced', + 'Added {{value0}} host{{value1}} to Orca.', + { value0: result.targets.length, value1: result.targets.length > 1 ? 's' : '' } + ) + ) + return { kind: 'added', count: result.targets.length } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshImportFailed', + 'Failed to import SSH config.' + ) + ) + return { kind: 'failed' } + } +} + +export async function loadSshConfigHostsForPicker( + ssh: SshApi, + args?: SshConfigHostListArgs +): Promise<{ ok: true; result: SshConfigHostListResult } | { ok: false; error: string }> { + try { + const listed: unknown = await ssh.listConfigHosts(args) + const result = normalizeSshConfigHostListResult(listed) + if (!result) { + throw new Error('Invalid SSH config host response') + } + return { ok: true, result } + } catch (error) { + return { + ok: false, + error: + error instanceof Error + ? error.message + : translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshConfigPickerLoadFailed', + 'Failed to read ~/.ssh/config.' + ) + } + } +} + +function normalizeSshConfigHostListResult(value: unknown): SshConfigHostListResult | null { + // Why: a renderer hot reload can briefly outlive the preload that returned the legacy array. + if (Array.isArray(value)) { + const hosts = value.slice(0, SSH_CONFIG_HOST_RESULT_LIMIT) as SshConfigHostSummary[] + return { + hosts, + totalHostCount: value.length, + newHostCount: value.filter( + (host): host is SshConfigHostSummary => + typeof host === 'object' && host !== null && host.alreadyInOrca === false + ).length, + matchCount: value.length, + hasMore: value.length > hosts.length + } + } + if (!value || typeof value !== 'object') { + return null + } + const result = value as Partial + return Array.isArray(result.hosts) && + typeof result.totalHostCount === 'number' && + typeof result.newHostCount === 'number' && + typeof result.matchCount === 'number' && + typeof result.hasMore === 'boolean' + ? (result as SshConfigHostListResult) + : null +} diff --git a/src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts b/src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts new file mode 100644 index 00000000000..c2c4709101a --- /dev/null +++ b/src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { isDuplicateSshTargetAlias } from './ssh-target-duplicate' + +describe('isDuplicateSshTargetAlias', () => { + it('matches by configHost alias', () => { + expect( + isDuplicateSshTargetAlias({ + existingTargets: [{ configHost: 'staging', label: 'Staging', host: '10.0.0.1' }], + configHost: 'staging', + label: 'Staging box', + host: 'staging.internal' + }) + ).toBe(true) + }) + + it('matches by label when configHost is empty', () => { + expect( + isDuplicateSshTargetAlias({ + existingTargets: [{ label: 'prod-box', host: 'prod-box' }], + configHost: '', + label: 'prod-box', + host: 'prod-box' + }) + ).toBe(true) + }) + + it('matches case-only alias variants like the config picker does', () => { + expect( + isDuplicateSshTargetAlias({ + existingTargets: [{ configHost: 'Staging', label: 'Staging', host: '10.0.0.1' }], + configHost: 'staging', + label: 'staging', + host: 'staging.internal' + }) + ).toBe(true) + }) + + // Parity with the picker: it greys out an alias that matches either field, so saving + // that same alias must be blocked rather than creating a second target for one host. + it('matches an existing label even when that target has a different configHost', () => { + expect( + isDuplicateSshTargetAlias({ + existingTargets: [{ configHost: 'box1', label: 'prod', host: '10.0.0.1' }], + configHost: 'Prod', + label: 'Prod', + host: 'prod.internal' + }) + ).toBe(true) + }) + + it('returns false for a new alias', () => { + expect( + isDuplicateSshTargetAlias({ + existingTargets: [{ configHost: 'staging', label: 'staging', host: 's.example' }], + configHost: 'prod', + label: 'prod', + host: 'p.example' + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/ssh-target-duplicate.ts b/src/renderer/src/components/sidebar/ssh-target-duplicate.ts new file mode 100644 index 00000000000..0062fa1df94 --- /dev/null +++ b/src/renderer/src/components/sidebar/ssh-target-duplicate.ts @@ -0,0 +1,33 @@ +import { normalizeSshConfigAlias } from '../../../../shared/ssh-config-alias' +import type { SshTarget } from '../../../../shared/ssh-types' + +/** True when an existing Orca host already owns this config alias / label. */ +export function isDuplicateSshTargetAlias({ + existingTargets, + configHost, + label, + host +}: { + existingTargets: readonly Pick[] + configHost: string + label: string + host: string +}): boolean { + // Why: the config picker's `alreadyInOrca` flag compares lowercased aliases; match it or the + // two checks disagree on case-only variants. + const alias = + normalizeSshConfigAlias(configHost) || + normalizeSshConfigAlias(label) || + normalizeSshConfigAlias(host) + if (!alias) { + return false + } + return existingTargets.some((target) => getOccupiedAliases(target).includes(alias)) +} + +/** Why: the picker treats configHost *and* label as owned, so the save check must too — + * otherwise an alias it greys out as "In Orca" is still savable as a second target. */ +function getOccupiedAliases(target: Pick): string[] { + const occupied = [target.configHost, target.label].map(normalizeSshConfigAlias).filter(Boolean) + return occupied.length > 0 ? occupied : [normalizeSshConfigAlias(target.host)].filter(Boolean) +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 81f9fbe23e9..7d5c09c8f22 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -127,6 +127,7 @@ import { } from '@/lib/project-host-setup-options' import { buildNewWorkspaceCreateTargetOptions, + findActionableFolderProjectGroup, getProjectGroupIdFromNewWorkspaceOptionId, type NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' @@ -613,6 +614,31 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const eligibleRepos = useMemo(() => getComposerEligibleRepos(repos), [repos]) + const hostOptions = useMemo( + () => + buildExecutionHostRegistry({ + repos, + settings, + hostSource: 'configured-only', + sshTargetLabels, + sshConnectionStates, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides: getHostDisplayLabelOverrides(settings) + }), + [ + repos, + settings, + sshConnectionStates, + sshTargetLabels, + runtimeEnvironments, + runtimeStatusByEnvironmentId + ] + ) + const actionableHostIds = useMemo( + () => new Set(hostOptions.map((host) => host.id)), + [hostOptions] + ) // Why: a runtime-owned SSH repo (active right after creating a per-workspace-env) is ineligible; seed from its local same-project sibling, not another project. const seedActiveRepoId = useMemo( () => resolveComposerActiveRepoId(repos, eligibleRepos, activeRepoId), @@ -650,14 +676,17 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS projectId: initialRunSeed.projectId, hostId: initialRunSeed.hostId, projectHostSetupId: initialRunSeed.projectHostSetupId, - focusedHostScope: workspaceHostScope + focusedHostScope: workspaceHostScope, + actionableHostIds }) const [internalRepoId, setInternalRepoId] = useState(resolvedInitialRepoId) const initialFolderProjectGroupId = initialProjectGroupId ?? draftProjectGroupId - const initialFolderProjectGroup = projectGroups.find( - (group) => group.id === initialFolderProjectGroupId && Boolean(group.parentPath?.trim()) - ) + const initialFolderProjectGroup = findActionableFolderProjectGroup({ + projectGroups, + groupId: initialFolderProjectGroupId, + actionableHostIds + }) const [selectedProjectGroupId, setSelectedProjectGroupId] = useState( initialFolderProjectGroup?.id ?? null ) @@ -666,12 +695,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const repoId = repoIdOverride ?? internalRepoId const selectedProjectGroup = useMemo( () => - selectedProjectGroupId - ? (projectGroups.find( - (group) => group.id === selectedProjectGroupId && Boolean(group.parentPath?.trim()) - ) ?? null) - : null, - [projectGroups, selectedProjectGroupId] + findActionableFolderProjectGroup({ + projectGroups, + groupId: selectedProjectGroupId, + actionableHostIds + }), + [actionableHostIds, projectGroups, selectedProjectGroupId] ) useEffect(() => { if (selectedProjectGroupId && !selectedProjectGroup) { @@ -686,14 +715,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ) { return } - const nextGroup = projectGroups.find( - (group) => group.id === initialFolderProjectGroupId && Boolean(group.parentPath?.trim()) - ) + const nextGroup = findActionableFolderProjectGroup({ + projectGroups, + groupId: initialFolderProjectGroupId, + actionableHostIds + }) if (nextGroup) { initialProjectGroupAppliedRef.current = true setSelectedProjectGroupId(nextGroup.id) } - }, [initialFolderProjectGroupId, projectGroups, selectedProjectGroupId]) + }, [actionableHostIds, initialFolderProjectGroupId, projectGroups, selectedProjectGroupId]) const isProjectGroupTarget = selectedProjectGroup !== null const folderSourceRepos = useMemo( () => getFolderSourceRepos(repos, projectGroups, selectedProjectGroup), @@ -742,9 +773,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS projects, projectHostSetups, draftRepoId: repoId, - focusedHostScope: workspaceHostScope + focusedHostScope: workspaceHostScope, + actionableHostIds }), - [eligibleRepos, projectHostSetups, projects, repoId, workspaceHostScope] + [actionableHostIds, eligibleRepos, projectHostSetups, projects, repoId, workspaceHostScope] ) const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false @@ -784,26 +816,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS !selectedProjectGroup && selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectHostSetupId : null - const hostOptions = useMemo( - () => - buildExecutionHostRegistry({ - repos, - settings, - sshTargetLabels, - sshConnectionStates, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - hostLabelOverrides: getHostDisplayLabelOverrides(settings) - }), - [ - repos, - settings, - sshConnectionStates, - sshTargetLabels, - runtimeEnvironments, - runtimeStatusByEnvironmentId - ] - ) const projectHostSetupOptions = useMemo( () => buildProjectHostSetupOptions({ @@ -2648,9 +2660,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS initialProjectGroupAppliedRef.current = true const projectGroupId = getProjectGroupIdFromNewWorkspaceOptionId(projectId) if (projectGroupId) { - const nextProjectGroup = projectGroups.find( - (group) => group.id === projectGroupId && Boolean(group.parentPath?.trim()) - ) + const nextProjectGroup = findActionableFolderProjectGroup({ + projectGroups, + groupId: projectGroupId, + actionableHostIds + }) if (!nextProjectGroup) { setSelectedProjectGroupId(null) setProjectError( @@ -2697,7 +2711,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS projects, projectHostSetups, projectId, - focusedHostScope: preferredHostId ?? workspaceHostScope + focusedHostScope: preferredHostId ?? workspaceHostScope, + actionableHostIds }) if (!nextRepoId) { return @@ -2706,6 +2721,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, [ eligibleRepos, + actionableHostIds, handleRepoChange, isProjectGroupTarget, linkedWorkItem, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index db95d954edd..b8580ef27f2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5083,15 +5083,40 @@ "pairingCommand": "orca serve --pairing-address ", "pairingHelpSuffix": "on the server and paste the printed pairing URL.", "sshImportAlreadySynced": "~/.ssh/config already in sync.", - "sshImportSynced": "Synced {{value0}} host{{value1}}.", + "sshImportSynced": "Added {{value0}} host{{value1}} to Orca.", "sshImportFailed": "Failed to import SSH config.", - "importing": "Importing...", - "importSshConfig": "Import ~/.ssh/config", + "sshAlreadyExists": "That SSH host is already in Orca.", "advanced": "Advanced", "linkDestination": "Link destination", "sshTunnel": "I am using an SSH tunnel", "sshTunnelHelp": "Otherwise, this link points back to this device and cannot identify the other computer.", - "loopbackBlocked": "Enable the SSH tunnel override or create a new link using the other host’s Tailscale or LAN address." + "loopbackBlocked": "Enable the SSH tunnel override or create a new link using the other host’s Tailscale or LAN address.", + "sshConfigPickerLoadFailed": "Failed to read ~/.ssh/config.", + "sshConfigPickerResolveFailed": "Failed to resolve that SSH config host.", + "sshConfigPickerRestartRequired": "Restart Orca to finish applying the SSH config picker update.", + "sshConfigPickerFilled": "Filled from {{value0}}. Review and Save.", + "sshConfigPickerTitle": "Choose from ~/.ssh/config", + "sshConfigPickerDescription": "Pick a host to fill the form. Nothing is saved until you click Save.", + "sshConfigPickerFilter": "Filter hosts…", + "sshConfigPickerHostsLabel": "SSH config hosts", + "sshConfigPickerLoading": "Reading ~/.ssh/config…", + "sshConfigPickerEmpty": "No hosts in ~/.ssh/config", + "sshConfigPickerNoMatch": "No matching hosts", + "sshConfigPickerEmptyHint": "Add a Host entry there, or go back and type the details manually.", + "sshConfigPickerNoMatchHint": "Try another filter, or go back and type manually.", + "sshConfigPickerMoreResults": "Showing the first {{value0}} matches. Narrow your filter to find more.", + "sshConfigPickerInOrca": "In Orca", + "sshConfigPickerPreviouslyRemoved": "Removed from Orca", + "sshConfigPickerBulkHint": "Bulk sync stays in Settings → SSH", + "sshConfigPickerBack": "Back", + "fillFromSshConfig": "Fill from ~/.ssh/config…", + "sshConfigPickerAddingAll": "Adding hosts…", + "sshConfigPickerAddAll": "Add all {{value0}} to Orca", + "sshConfigPickerNoNewHosts": "No new hosts to add", + "sshConfigPickerAddAllEmpty": "Add all to Orca", + "identityFileFromConfigHint": "Left empty on purpose: Orca uses every key ~/.ssh/config resolves for {{value0}}. Type a path to use just that key.", + "sshConfigPickerRetry": "Try again", + "sshConfigPickerResolving": "Reading…" }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "Reconnection failed", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2ff00f67eba..b9d562579e3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5013,8 +5013,6 @@ "sshImportAlreadySynced": "~/.ssh/config ya está sincronizado.", "sshImportSynced": "Se sincronizó {{value0}} host{{value1}}.", "sshImportFailed": "No se pudo importar la configuración SSH.", - "importing": "Importando...", - "importSshConfig": "Importar ~/.ssh/config", "sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay.", "advanced": "Advanced", "linkDestination": "Destino del enlace", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6a92f631f34..f45c99439bd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5013,8 +5013,6 @@ "sshImportAlreadySynced": "~/.ssh/config はすでに同期されています。", "sshImportSynced": "{{value0}} ホスト{{value1}} を同期しました。", "sshImportFailed": "SSH 構成のインポートに失敗しました。", - "importing": "インポート中...", - "importSshConfig": "~/.ssh/config をインポートします", "sshPersistenceDefault": "このホスト上のリモートターミナルは、終了するかリレーをリセットするまで動作し続けます。", "advanced": "詳細", "linkDestination": "リンク先", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ddfb7afcf86..1241301c0a8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5013,8 +5013,6 @@ "sshImportAlreadySynced": "~/.ssh/config가 이미 동기화되어 있습니다.", "sshImportSynced": "{{value0}} 호스트{{value1}}을(를) 동기화했습니다.", "sshImportFailed": "SSH 구성을 가져오지 못했습니다.", - "importing": "가져오는 중...", - "importSshConfig": "~/.ssh/config 가져오기", "sshPersistenceDefault": "이 호스트의 원격 터미널은 종료하거나 릴레이를 재설정할 때까지 계속 실행됩니다.", "advanced": "고급", "linkDestination": "링크 대상", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 42d8c5e2dd4..04117976acc 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5025,8 +5025,6 @@ "sshImportAlreadySynced": "~/.ssh/config 已同步。", "sshImportSynced": "已同步 {{value0}} 个主机{{value1}}。", "sshImportFailed": "导入 SSH 配置失败。", - "importing": "正在导入...", - "importSshConfig": "导入 ~/.ssh/config", "sshPersistenceDefault": "此主机上的远程终端会保持运行,直到你结束它们或重置中继。", "advanced": "Advanced", "linkDestination": "链接目标", diff --git a/src/renderer/src/lib/new-workspace-project-options.test.ts b/src/renderer/src/lib/new-workspace-project-options.test.ts index 67c25ab2598..f6130d8f861 100644 --- a/src/renderer/src/lib/new-workspace-project-options.test.ts +++ b/src/renderer/src/lib/new-workspace-project-options.test.ts @@ -3,6 +3,7 @@ import { NEW_WORKSPACE_PROJECT_OPTION_QUERY_MAX_BYTES, buildNewWorkspaceFolderSourceOptions, buildNewWorkspaceProjectOptions, + findActionableFolderProjectGroup, getRepoIdFromNewWorkspaceFolderSourceOptionId, isNewWorkspaceProjectOptionQueryTooLarge, searchNewWorkspaceProjectOptions, @@ -108,6 +109,35 @@ describe('buildNewWorkspaceProjectOptions', () => { expect(options.map((option) => option.id)).toEqual(['github:stablyai/orca']) }) + it('excludes projects configured only on removed hosts', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [project()], + projectHostSetups: [ + setup({ id: 'removed-setup', hostId: 'ssh:removed', repoId: 'ssh-repo' }) + ], + eligibleRepos: [repo('ssh-repo', { connectionId: 'removed' })], + hosts: [{ id: 'local', label: 'Local Mac' }] + }) + + expect(options).toEqual([]) + }) + + it('keeps projects with an actionable sibling setup', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [project()], + projectHostSetups: [ + setup({ id: 'local-setup', hostId: 'local', repoId: 'local-repo' }), + setup({ id: 'removed-setup', hostId: 'ssh:removed', repoId: 'ssh-repo' }) + ], + eligibleRepos: [repo('local-repo'), repo('ssh-repo', { connectionId: 'removed' })], + hosts: [{ id: 'local', label: 'Local Mac' }] + }) + + expect(options).toEqual([ + expect.objectContaining({ id: 'github:stablyai/orca', detail: 'stablyai/orca' }) + ]) + }) + it('shows configured directories when project names are duplicated', () => { const options = buildNewWorkspaceProjectOptions({ projects: [ @@ -432,9 +462,16 @@ describe('buildNewWorkspaceCreateTargetOptions', () => { projects: [project()], projectHostSetups: [setup({ id: 'local-setup', repoId: 'local-repo' })], eligibleRepos: [repo('local-repo')], + hosts: [{ id: 'local', label: 'Local Mac' }], projectGroups: [ group({ id: 'folder-group', name: 'Platform', parentPath: '/tmp/platform' }), - group({ id: 'org-group', name: 'Org', parentPath: null }) + group({ id: 'org-group', name: 'Org', parentPath: null }), + group({ + id: 'removed-folder-group', + name: 'Removed Remote', + parentPath: '/srv/removed', + connectionId: 'removed' + }) ] }) @@ -450,3 +487,50 @@ describe('buildNewWorkspaceCreateTargetOptions', () => { }) }) }) + +describe('findActionableFolderProjectGroup', () => { + const folderGroups = [ + group({ id: 'local-group' }), + group({ id: 'ssh-group', connectionId: 'box' }), + group({ id: 'repo-group', parentPath: null }) + ] + + it('finds a folder group whose host is actionable', () => { + expect( + findActionableFolderProjectGroup({ + projectGroups: folderGroups, + groupId: 'ssh-group', + actionableHostIds: new Set(['ssh:box']) + }) + ).toBe(folderGroups[1]) + }) + + // Regression: the composer's initial-group restoration skipped the actionable-host + // check, so a removed host could still back a folder workspace. + it('rejects a folder group whose host is unavailable', () => { + expect( + findActionableFolderProjectGroup({ + projectGroups: folderGroups, + groupId: 'ssh-group', + actionableHostIds: new Set(['local']) + }) + ).toBeNull() + }) + + it('rejects repo groups and missing ids', () => { + expect( + findActionableFolderProjectGroup({ + projectGroups: folderGroups, + groupId: 'repo-group', + actionableHostIds: new Set(['local']) + }) + ).toBeNull() + expect( + findActionableFolderProjectGroup({ + projectGroups: folderGroups, + groupId: null, + actionableHostIds: new Set(['local']) + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/new-workspace-project-options.ts b/src/renderer/src/lib/new-workspace-project-options.ts index bfa47e2a63b..58cab355340 100644 --- a/src/renderer/src/lib/new-workspace-project-options.ts +++ b/src/renderer/src/lib/new-workspace-project-options.ts @@ -2,6 +2,12 @@ import { projectHostSetupProjectionFromRepos } from '../../../shared/project-hos import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import { getDuplicateProjectDetailsById, type ProjectSetupDirectory @@ -92,11 +98,16 @@ export function buildNewWorkspaceProjectOptions( const { projects, projectHostSetups } = getProjectModel(input) const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) const hostLabelById = new Map((input.hosts ?? []).map((host) => [host.id, host.label])) + const liveHostIds = input.hosts ? new Set(input.hosts.map((host) => host.id)) : null const readySetupCountsByProjectId = new Map() const setupDirectoriesByProjectId = new Map() for (const setup of projectHostSetups) { - if (setup.setupState !== 'ready' || !eligibleRepoIds.has(setup.repoId)) { + if ( + setup.setupState !== 'ready' || + !eligibleRepoIds.has(setup.repoId) || + (liveHostIds && !liveHostIds.has(setup.hostId)) + ) { continue } readySetupCountsByProjectId.set( @@ -158,6 +169,41 @@ function getProjectGroupDetail(group: ProjectGroup): string { return group.parentPath?.trim() || 'Repo group' } +export function getNewWorkspaceProjectGroupHostId(group: ProjectGroup): ExecutionHostId { + const executionHost = parseExecutionHostId(group.executionHostId) + if (executionHost) { + return executionHost.id + } + const connectionId = group.connectionId?.trim() + return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID +} + +/** + * A folder workspace can only be backed by a group whose host is still actionable — a removed + * or unreachable host must not be selectable, restorable from a draft, or kept once selected. + */ +export function findActionableFolderProjectGroup({ + projectGroups, + groupId, + actionableHostIds +}: { + projectGroups: readonly ProjectGroup[] + groupId: string | null | undefined + actionableHostIds: ReadonlySet +}): ProjectGroup | null { + if (!groupId) { + return null + } + return ( + projectGroups.find( + (group) => + group.id === groupId && + Boolean(group.parentPath?.trim()) && + actionableHostIds.has(getNewWorkspaceProjectGroupHostId(group)) + ) ?? null + ) +} + export function buildNewWorkspaceFolderSourceOptions( repos: readonly Repo[] ): NewWorkspaceProjectOption[] { @@ -178,8 +224,13 @@ export function buildNewWorkspaceCreateTargetOptions({ ...projectInput }: BuildNewWorkspaceCreateTargetOptionsInput): NewWorkspaceProjectOption[] { const projectOptions = buildNewWorkspaceProjectOptions(projectInput) + const liveHostIds = projectInput.hosts ? new Set(projectInput.hosts.map((host) => host.id)) : null const groupOptions = projectGroups - .filter((group) => Boolean(group.parentPath?.trim())) + .filter( + (group) => + Boolean(group.parentPath?.trim()) && + (!liveHostIds || liveHostIds.has(getNewWorkspaceProjectGroupHostId(group))) + ) .map((group) => ({ kind: 'project-group' as const, id: getProjectGroupOptionId(group.id), diff --git a/src/renderer/src/lib/project-host-setup-options.test.ts b/src/renderer/src/lib/project-host-setup-options.test.ts index ecd4d74495e..a5e5e2056d4 100644 --- a/src/renderer/src/lib/project-host-setup-options.test.ts +++ b/src/renderer/src/lib/project-host-setup-options.test.ts @@ -69,6 +69,7 @@ describe('buildProjectHostSetupOptions', () => { const options = buildProjectHostSetupOptions({ projectId: 'project-1', eligibleRepos: [repo('local-repo'), repo('remote-repo')], + hosts: [host('local'), host('ssh:builder')], projectHostSetups: [ setup('remote', 'project-1', 'ssh:builder', 'remote-repo'), setup('local', 'project-1', 'local', 'local-repo') @@ -187,6 +188,7 @@ describe('buildProjectHostSetupOptions', () => { const options = buildProjectHostSetupOptions({ projectId: 'project-1', eligibleRepos: [repo('ready-repo')], + hosts: [host('local')], projectHostSetups: [ setup('ready', 'project-1', 'local', 'ready-repo'), setup('setting-up', 'project-1', 'ssh:builder', 'missing-repo', { @@ -199,6 +201,23 @@ describe('buildProjectHostSetupOptions', () => { expect(options.map((option) => option.id)).toEqual(['ready']) }) + it('omits ready setups whose host is no longer actionable', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo'), repo('removed-repo')], + hosts: [host('local')], + projectHostSetups: [ + setup('local', 'project-1', 'local', 'local-repo'), + setup('removed', 'project-1', 'ssh:removed', 'removed-repo') + ] + }) + + expect(options.map((option) => option.id)).toEqual(['local']) + expect(options).not.toContainEqual( + expect.objectContaining({ connectAction: { kind: 'ssh', targetId: 'removed' } }) + ) + }) + it('collapses duplicate ready setups on one host to the setup creation actually uses', () => { // Why: a linked worktree added as its own project projected a second ready `local` setup for the // same project, which rendered as repeated identical "Local Mac" rows separated only by path. @@ -249,7 +268,7 @@ describe('buildProjectHostSetupOptions', () => { id: 'needs-setup:ssh:builder', kind: 'needs-setup', label: 'Builder', - detail: 'Project not set up on this host', + detail: 'Project location not set', isAvailable: true }) ]) diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts index 4323a486032..9a8039cf19f 100644 --- a/src/renderer/src/lib/project-host-setup-options.ts +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -60,14 +60,14 @@ type BuildProjectHostSetupOptionsInput = { projectId: string | null projectHostSetups: readonly ProjectHostSetup[] eligibleRepos: readonly Repo[] - hosts?: readonly ExecutionHostRegistryEntry[] + hosts: readonly ExecutionHostRegistryEntry[] } export function buildProjectHostSetupOptions({ projectId, projectHostSetups, eligibleRepos, - hosts = [] + hosts }: BuildProjectHostSetupOptionsInput): ProjectHostSetupOption[] { if (!projectId) { return [] @@ -122,6 +122,7 @@ function buildReadySetupOptions({ setup.projectId === projectId && setup.setupState === 'ready' && eligibleRepoIds.has(setup.repoId) && + Boolean(host) && !isEphemeralVmProjectHost(host) && !isRuntimeOwnedSshSetupHost(setup.hostId) ) @@ -181,7 +182,7 @@ function buildNeedsSetupOptions({ detail: availability.isAvailable ? pendingSetup ? getPendingSetupDetail(pendingSetup) - : 'Project not set up on this host' + : 'Project location not set' : availability.detail, isAvailable: availability.isAvailable, attention: host.health === 'error', diff --git a/src/renderer/src/lib/project-host-workspace-target.test.ts b/src/renderer/src/lib/project-host-workspace-target.test.ts index e0e3281a079..528773d8e40 100644 --- a/src/renderer/src/lib/project-host-workspace-target.test.ts +++ b/src/renderer/src/lib/project-host-workspace-target.test.ts @@ -200,6 +200,29 @@ describe('project-host workspace target resolution', () => { }) }) + it('does not fall back to another host when only a host is selected', () => { + const localRepo = makeRepo('orca-local') + const remoteRepo = makeRepo('orca-ssh', { connectionId: 'builder' }) + const projects = [makeProject('github:stablyai/orca', ['orca-local', 'orca-ssh'])] + const projectHostSetups = [ + makeSetup('orca-local', 'github:stablyai/orca', 'local', 'orca-local') + ] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [localRepo, remoteRepo], + projects, + projectHostSetups, + draftRepoId: 'orca-local', + hostId: 'ssh:builder', + actionableHostIds: new Set(['local', 'ssh:builder']) + }) + ).toEqual({ + status: 'unavailable', + reason: 'project-not-set-up-on-host' + }) + }) + it('reports setup-not-ready when the selected host has pending setup metadata', () => { const repo = makeRepo('orca') const projects = [makeProject('github:stablyai/orca', ['orca'])] @@ -246,6 +269,46 @@ describe('project-host workspace target resolution', () => { }) }) + it('does not resolve workspace creation through a removed host', () => { + const remoteRepo = makeRepo('remote-repo', { connectionId: 'removed' }) + const projects = [makeProject('repo:remote', ['remote-repo'])] + const projectHostSetups = [ + makeSetup('removed-setup', 'repo:remote', 'ssh:removed', 'remote-repo') + ] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [remoteRepo], + projects, + projectHostSetups, + draftRepoId: remoteRepo.id, + projectHostSetupId: 'removed-setup', + actionableHostIds: new Set(['local']) + }) + ).toEqual({ status: 'unavailable', reason: 'setup-not-found' }) + }) + + it('does not silently switch an explicit setup id to an actionable sibling host', () => { + const remoteRepo = makeRepo('remote-repo', { connectionId: 'removed' }) + const localRepo = makeRepo('local-repo') + const projects = [makeProject('repo:orca', ['remote-repo', 'local-repo'])] + const projectHostSetups = [ + makeSetup('removed-setup', 'repo:orca', 'ssh:removed', 'remote-repo'), + makeSetup('local-setup', 'repo:orca', 'local', 'local-repo') + ] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: [remoteRepo, localRepo], + projects, + projectHostSetups, + draftRepoId: remoteRepo.id, + projectHostSetupId: 'removed-setup', + actionableHostIds: new Set(['local']) + }) + ).toEqual({ status: 'unavailable', reason: 'setup-not-found' }) + }) + // Regression: selecting a project in the new-workspace dropdown must not be // pinned to the host of the currently-active workspace. Each project below is // set up on exactly one (different) host; picking the other project while the diff --git a/src/renderer/src/lib/project-host-workspace-target.ts b/src/renderer/src/lib/project-host-workspace-target.ts index 2a7c776eec6..a4234f7686d 100644 --- a/src/renderer/src/lib/project-host-workspace-target.ts +++ b/src/renderer/src/lib/project-host-workspace-target.ts @@ -40,6 +40,7 @@ type ProjectHostWorkspaceTargetInput = { hostId?: ExecutionHostId | null projectHostSetupId?: string | null focusedHostScope?: ExecutionHostScope | null + actionableHostIds?: ReadonlySet } type ProjectSetupModel = { @@ -120,13 +121,23 @@ export function resolveWorkspaceCreationTarget( const model = getProjectSetupModel(input) const repoById = new Map(eligibleRepos.map((repo) => [repo.id, repo])) - const setups = model?.setups ?? [] + const actionableHostIds = input.actionableHostIds + const allSetups = model?.setups ?? [] + const setups = actionableHostIds + ? allSetups.filter((setup) => actionableHostIds.has(setup.hostId)) + : allSetups if (projectHostSetupId) { - const setup = setups.find((entry) => entry.id === projectHostSetupId) + const setup = allSetups.find((entry) => entry.id === projectHostSetupId) if (!setup) { return { status: 'unavailable', reason: 'setup-not-found' } } + if (actionableHostIds && !actionableHostIds.has(setup.hostId)) { + // Why: the caller named this exact setup. Silently creating the workspace on a + // sibling host would put files (and any agent run) somewhere the user never chose, + // so fail closed and let them re-pick a host. + return { status: 'unavailable', reason: 'setup-not-found' } + } if (!isReadySetup(setup)) { return { status: 'unavailable', reason: 'setup-not-ready' } } @@ -192,6 +203,9 @@ export function resolveWorkspaceCreationTarget( if (target) { return { status: 'ready', target } } + // Why: the caller named this host. Falling through to the legacy repo (or any other + // actionable host) would create the workspace somewhere the user never selected. + return { status: 'unavailable', reason: 'project-not-set-up-on-host' } } const repoId = resolveComposerRepoId(input) @@ -200,14 +214,22 @@ export function resolveWorkspaceCreationTarget( return { status: 'unavailable', reason: 'no-eligible-repo' } } + const projectedLegacySetup = projectHostSetupProjectionFromRepos([legacyRepo]).setups[0] const legacySetup = setups.find((setup) => setup.repoId === legacyRepo.id && isReadySetup(setup)) ?? - projectHostSetupProjectionFromRepos([legacyRepo]).setups[0] + (!actionableHostIds || actionableHostIds.has(projectedLegacySetup.hostId) + ? projectedLegacySetup + : null) const legacyTarget = legacySetup ? createTarget(legacySetup, repoById) : null - if (!legacyTarget) { - return { status: 'unavailable', reason: 'setup-not-found' } + if (legacyTarget) { + return { status: 'ready', target: legacyTarget } } - return { status: 'ready', target: legacyTarget } + const fallbackTarget = actionableHostIds + ? findReadySetupTarget(setups, repoById, () => true) + : null + return fallbackTarget + ? { status: 'ready', target: fallbackTarget } + : { status: 'unavailable', reason: 'setup-not-found' } } export function resolveWorkspaceCreationRepoId(input: ProjectHostWorkspaceTargetInput): string { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index b4f4a562ce9..dc9903bea2d 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -3250,6 +3250,15 @@ function createSshApi(): NonNullable['ssh']> { Promise.reject(new Error('SSH target management is unavailable in the web client.')), removeTarget: () => Promise.resolve(), importConfig: () => Promise.resolve({ targets: [], repoReadoptions: [] }), + listConfigHosts: () => + Promise.resolve({ + hosts: [], + totalHostCount: 0, + newHostCount: 0, + matchCount: 0, + hasMore: false + }), + resolveConfigHost: () => Promise.resolve(null), connect: async (args) => { const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( 'ssh.connect', diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts index bd0bcfae227..c522d2e7b02 100644 --- a/src/shared/execution-host-registry.test.ts +++ b/src/shared/execution-host-registry.test.ts @@ -57,6 +57,41 @@ describe('execution host registry', () => { ]) }) + it('excludes repository references from configured-only registries', () => { + const hosts = buildExecutionHostRegistry({ + repos: [ + { connectionId: 'removed-ssh' }, + { connectionId: null, executionHostId: 'runtime:removed-runtime' } + ], + settings: { activeRuntimeEnvironmentId: 'removed-focused-runtime' }, + hostSource: 'configured-only', + sshTargetLabels: new Map([['saved-ssh', 'Saved SSH']]), + runtimeEnvironments: [{ id: 'saved-runtime', name: 'Saved Runtime' }] + }) + + expect(hosts.map((host) => host.id)).toEqual([ + 'local', + 'runtime:saved-runtime', + 'ssh:saved-ssh' + ]) + }) + + it('keeps repository references in the default reference-inclusive registry', () => { + const hosts = buildExecutionHostRegistry({ + repos: [ + { connectionId: 'removed-ssh' }, + { connectionId: null, executionHostId: 'runtime:removed-runtime' } + ], + settings: { activeRuntimeEnvironmentId: null } + }) + + expect(hosts.map((host) => host.id)).toEqual([ + 'local', + 'runtime:removed-runtime', + 'ssh:removed-ssh' + ]) + }) + it('hides runtime-owned (ephemeral VM) SSH targets from repo-derived hosts', () => { const hosts = buildExecutionHostRegistry({ // A VM-backed repo carries the hidden runtime-owned target on both fields. diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index 6e966329228..bceddd86a4b 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -54,6 +54,8 @@ type RuntimeHostStatus = { type RuntimeStatusByEnvironmentId = ReadonlyMap +export type ExecutionHostSource = 'configured-only' | 'include-references' + function normalizeHostPart(value: string | null | undefined): string | null { const trimmed = value?.trim() return trimmed ? trimmed : null @@ -177,6 +179,7 @@ function addRuntimeHost( export function buildExecutionHostRegistry(args: { repos: readonly Pick[] settings: Pick | null | undefined + hostSource?: ExecutionHostSource sshTargetLabels?: ReadonlyMap sshConnectionStates?: ReadonlyMap runtimeEnvironments?: readonly RuntimeEnvironmentSummary[] @@ -219,7 +222,7 @@ export function buildExecutionHostRegistry(args: { const focusedHost = getSettingsFocusedExecutionHostId(args.settings) const parsedFocusedHost = parseExecutionHostId(focusedHost) - if (parsedFocusedHost?.kind === 'runtime') { + if (parsedFocusedHost?.kind === 'runtime' && args.hostSource !== 'configured-only') { addRuntimeHost( hosts, parsedFocusedHost.environmentId, @@ -230,21 +233,23 @@ export function buildExecutionHostRegistry(args: { } const sshTargetIds = new Set() - for (const repo of args.repos) { - const parsedHost = parseExecutionHostId(repo.executionHostId) - if (parsedHost?.kind === 'runtime') { - addRuntimeHost( - hosts, - parsedHost.environmentId, - parsedHost.environmentId, - undefined, - args.runtimeStatusByEnvironmentId - ) - } - // Why: a VM-backed repo's executionHostId is `ssh:runtime-ssh-`. Runtime-owned - // targets are hidden, so they must not become visible SSH run-target hosts here. - if (parsedHost?.kind === 'ssh' && !isRuntimeOwnedSshTargetId(parsedHost.targetId)) { - sshTargetIds.add(parsedHost.targetId) + if (args.hostSource !== 'configured-only') { + for (const repo of args.repos) { + const parsedHost = parseExecutionHostId(repo.executionHostId) + if (parsedHost?.kind === 'runtime') { + addRuntimeHost( + hosts, + parsedHost.environmentId, + parsedHost.environmentId, + undefined, + args.runtimeStatusByEnvironmentId + ) + } + // Why: a VM-backed repo's executionHostId is `ssh:runtime-ssh-`. Runtime-owned + // targets are hidden, so they must not become visible SSH run-target hosts here. + if (parsedHost?.kind === 'ssh' && !isRuntimeOwnedSshTargetId(parsedHost.targetId)) { + sshTargetIds.add(parsedHost.targetId) + } } } for (const targetId of args.sshTargetLabels?.keys() ?? []) { @@ -253,10 +258,12 @@ export function buildExecutionHostRegistry(args: { sshTargetIds.add(normalized) } } - for (const repo of args.repos) { - const targetId = normalizeHostPart(repo.connectionId) - if (targetId && !isRuntimeOwnedSshTargetId(targetId)) { - sshTargetIds.add(targetId) + if (args.hostSource !== 'configured-only') { + for (const repo of args.repos) { + const targetId = normalizeHostPart(repo.connectionId) + if (targetId && !isRuntimeOwnedSshTargetId(targetId)) { + sshTargetIds.add(targetId) + } } } diff --git a/src/shared/ssh-config-alias.ts b/src/shared/ssh-config-alias.ts new file mode 100644 index 00000000000..ea93dd75de4 --- /dev/null +++ b/src/shared/ssh-config-alias.ts @@ -0,0 +1,8 @@ +/** + * Canonical form for an SSH config alias. OpenSSH matches Host patterns + * case-insensitively, so the picker, import reconciliation, delete tombstones + * and the save-time duplicate check must all compare aliases through this. + */ +export function normalizeSshConfigAlias(alias: string | null | undefined): string { + return alias ? alias.trim().toLowerCase() : '' +} diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 230e53c6a5b..fd6a9a3bcb0 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -92,6 +92,54 @@ export type SshConfigImportResult = { repoReadoptions: SshRepoReadoption[] } +/** Concrete Host entry from ~/.ssh/config, for pickers that prefill the add-host form. */ +export type SshConfigHostSummary = { + alias: string + hostname: string + port: number + username: string + identityFile?: string + proxyCommand?: string + jumpHost?: string + /** True when an Orca SSH target already uses this config alias. */ + alreadyInOrca: boolean + /** + * True when the user deleted this alias from Orca (tombstone). Still listed so they + * can re-pick it; passive import and "Add all" keep it out until re-adopt / save. + */ + previouslyRemoved?: boolean +} + +/** Max hosts one picker query returns; shared so the renderer's copy cannot drift. */ +export const SSH_CONFIG_HOST_RESULT_LIMIT = 100 + +export type SshConfigHostListResult = { + hosts: SshConfigHostSummary[] + totalHostCount: number + newHostCount: number + matchCount: number + hasMore: boolean +} + +/** `refresh` re-reads ~/.ssh/config; filter keystrokes reuse the cached parse. */ +export type SshConfigHostListArgs = { query?: string; refresh?: boolean } + +/** Effective OpenSSH values used to prefill one manually managed target. */ +export type SshConfigHostResolution = { + alias: string + hostname: string + port: number + username: string + identityFiles: string[] + identityAgent?: string + identitiesOnly: boolean + forwardAgent: boolean + gssapiAuthentication?: boolean + proxyCommand?: string + proxyUseFdpass: boolean + jumpHost?: string +} + export type SavedPortForward = { localPort: number remoteHost: string diff --git a/tests/e2e/helpers/ssh-config-host-picker.ts b/tests/e2e/helpers/ssh-config-host-picker.ts new file mode 100644 index 00000000000..482eb9ed84e --- /dev/null +++ b/tests/e2e/helpers/ssh-config-host-picker.ts @@ -0,0 +1,282 @@ +/** + * Shared helpers for SSH config host picker / import E2E specs. + * Prefer role/label locators and user-visible copy over ids / data-*. + */ + +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Locator, Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' + +export function makeSshConfigHostPrefix(): string { + return `e2e-ssh-cfg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` +} + +export type SeededSshConfigHost = { + alias: string + hostname: string + user: string + port: number +} + +export function hostEndpointSummary( + host: Pick +): string { + return `${host.user}@${host.hostname}:${host.port}` +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function buildSshConfigBody(hosts: SeededSshConfigHost[]): string { + return hosts + .map( + (host) => + `Host ${host.alias}\n HostName ${host.hostname}\n User ${host.user}\n Port ${host.port}\n` + ) + .join('\n') +} + +/** Write ~/.ssh/config into the Electron-isolated HOME (same path os.homedir() uses). */ +export async function seedIsolatedSshConfig( + electronApp: ElectronApplication, + configBody: string +): Promise { + const home = await electronApp.evaluate(({ app }) => app.getPath('home')) + const sshDir = path.join(home, '.ssh') + mkdirSync(sshDir, { recursive: true, mode: 0o700 }) + writeFileSync(path.join(sshDir, 'config'), configBody, { mode: 0o600 }) + return home +} + +export async function dismissTransientAnnouncement(page: Page): Promise { + const maybeLaterButton = page.getByRole('button', { name: 'Maybe Later' }) + const visible = await expect(maybeLaterButton) + .toBeVisible({ timeout: 1_000 }) + .then(() => true) + .catch(() => false) + if (visible) { + await maybeLaterButton.click() + } +} + +export async function closeSettingsPage(page: Page): Promise { + await page.evaluate(() => { + window.__store?.getState().closeSettingsPage() + }) + await expect(page.getByPlaceholder('Search settings')) + .toBeHidden({ timeout: 5_000 }) + .catch(() => undefined) +} + +export async function closeOpenDialogs(page: Page): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + const dialogCount = await page.getByRole('dialog').count() + if (dialogCount === 0) { + return + } + const dialog = page.getByRole('dialog').last() + const cancelOrBack = dialog.getByRole('button', { name: /^(Cancel|Back)$/ }) + await ((await cancelOrBack + .first() + .isVisible() + .catch(() => false)) + ? cancelOrBack.first().click() + : page.keyboard.press('Escape')) + await expect + .poll(async () => page.getByRole('dialog').count(), { timeout: 3_000 }) + .toBeLessThan(dialogCount) + .catch(() => undefined) + } +} + +/** Leave settings / overlays so the main shell (Add Project) is reachable. */ +export async function returnToAppShell(page: Page): Promise { + await closeOpenDialogs(page) + await closeSettingsPage(page) + await closeOpenDialogs(page) +} + +/** Add Project → Host → Add remote host → Add SSH host → form dialog. */ +export async function openAddSshHostDialog(page: Page): Promise { + await returnToAppShell(page) + await page + .getByRole('button', { name: /Add Project/i }) + .first() + .click() + const addProjectDialog = page.getByRole('dialog', { name: /Add a project/i }) + await expect(addProjectDialog).toBeVisible({ timeout: 10_000 }) + + const hostCombobox = addProjectDialog.getByRole('combobox') + await expect(hostCombobox).toBeVisible() + await hostCombobox.click() + + // cmdk exposes options; accessible name includes the detail line. + const addRemoteHostItem = page.getByRole('option', { name: /Add remote host/i }) + await expect(addRemoteHostItem).toBeVisible({ timeout: 5_000 }) + await addRemoteHostItem.click() + + // Nested popover is portaled; name includes the “existing machine over SSH” detail. + const addSshHostAction = page.getByRole('button', { + name: /Add SSH host.*existing machine over SSH/i + }) + await expect(addSshHostAction).toBeVisible({ timeout: 5_000 }) + await addSshHostAction.click() + + const sshDialog = page.getByRole('dialog', { name: 'Add SSH host' }) + await expect(sshDialog).toBeVisible({ timeout: 10_000 }) + await expect(sshDialog.getByRole('heading', { name: 'Add SSH host' })).toBeVisible() + return sshDialog +} + +export async function openSshConfigHostPicker(page: Page): Promise { + const sshDialog = await openAddSshHostDialog(page) + await sshDialog.getByRole('button', { name: /Fill from ~\/\.ssh\/config/i }).click() + const pickerDialog = page.getByRole('dialog', { name: 'Choose from ~/.ssh/config' }) + await expect(pickerDialog).toBeVisible({ timeout: 10_000 }) + await expect( + pickerDialog.getByRole('heading', { name: 'Choose from ~/.ssh/config' }) + ).toBeVisible() + // Wait past the loading empty-state before asserting host rows. + await expect(pickerDialog.getByText('Reading ~/.ssh/config…')).toBeHidden({ timeout: 10_000 }) + return pickerDialog +} + +/** Settings → SSH pane: section the user sees under the “SSH Hosts” heading. */ +export async function openSshHostSettings(page: Page): Promise { + await closeOpenDialogs(page) + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state) { + throw new Error('store unavailable') + } + state.openSettingsTarget({ pane: 'ssh', repoId: null }) + state.openSettingsPage() + }) + await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 }) + await dismissTransientAnnouncement(page) + + const sshSection = page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'SSH Hosts' }) }) + await expect(sshSection).toBeVisible({ timeout: 10_000 }) + await expect(sshSection.getByRole('button', { name: 'Import' })).toBeVisible() + await expect(sshSection.getByRole('button', { name: 'Add Target' })).toBeVisible() + return sshSection +} + +/** Form fields on Add SSH host — labels the user reads. */ +export function addSshHostFormFields(dialog: Locator): { + label: Locator + host: Locator + username: Locator + port: Locator + identityFile: Locator +} { + return { + label: dialog.getByLabel('Label', { exact: true }), + host: dialog.getByLabel('Host or alias'), + username: dialog.getByLabel('Username'), + port: dialog.getByLabel('Port'), + identityFile: dialog.getByLabel('Identity file') + } +} + +/** + * Picker row for one config Host: the list button whose accessible name includes + * the alias and the user@host:port line the user sees. + */ +export function configHostRow( + pickerDialog: Locator, + host: Pick +): Locator { + const alias = escapeRegExp(host.alias) + const endpoint = escapeRegExp(hostEndpointSummary(host)) + return pickerDialog + .getByRole('list', { name: 'SSH config hosts' }) + .getByRole('button', { name: new RegExp(`${alias}[\\s\\S]*${endpoint}`) }) +} + +/** + * Assert a saved host appears in Settings. Card subtitle is + * `user@host:port • terminal timeout: …`, so match the endpoint as a prefix. + */ +export async function expectSshHostListedInSettings( + sshSection: Locator, + host: SeededSshConfigHost +): Promise { + await expect(sshSection.getByText(host.alias, { exact: true })).toBeVisible({ timeout: 10_000 }) + await expect( + sshSection.getByText(new RegExp(`^${escapeRegExp(hostEndpointSummary(host))}\\b`)) + ).toBeVisible() +} + +export async function expectSshHostAbsentFromSettings( + sshSection: Locator, + host: SeededSshConfigHost +): Promise { + await expect(sshSection.getByText(host.alias, { exact: true })).toHaveCount(0) + await expect( + sshSection.getByText(new RegExp(escapeRegExp(hostEndpointSummary(host)))) + ).toHaveCount(0) +} +export async function seedOrcaSshTargetMatchingAlias( + page: Page, + args: { alias: string; hostname: string; username?: string; port?: number } +): Promise { + return page.evaluate(async ({ alias, hostname, username, port }) => { + const result = await window.api.ssh.addTarget({ + target: { + label: alias, + configHost: alias, + host: hostname, + port: port ?? 22, + username: username ?? 'deploy', + relayGracePeriodSeconds: 60 + } + }) + window.__store?.getState().recordSshRepoReadoptions(result.repoReadoptions) + return result.target.id + }, args) +} + +export async function removeSshTargetsByPrefix(page: Page, prefix: string): Promise { + await page.evaluate(async (labelPrefix) => { + const targets = (await window.api.ssh.listTargets()) as { + id: string + label: string + configHost?: string + }[] + for (const target of targets) { + const matches = + target.label.startsWith(labelPrefix) || + (target.configHost != null && target.configHost.startsWith(labelPrefix)) + if (!matches) { + continue + } + try { + await window.api.ssh.removeTarget({ id: target.id }) + } catch { + // Best-effort cleanup. + } + } + }, prefix) +} + +export async function removeSshTargetByAlias(page: Page, alias: string): Promise { + await page.evaluate(async (hostAlias) => { + const targets = (await window.api.ssh.listTargets()) as { + id: string + label: string + configHost?: string + }[] + const match = targets.find( + (target) => target.configHost === hostAlias || target.label === hostAlias + ) + if (!match) { + throw new Error(`No SSH target for alias ${hostAlias}`) + } + await window.api.ssh.removeTarget({ id: match.id }) + }, alias) +} diff --git a/tests/e2e/ssh-config-host-import.spec.ts b/tests/e2e/ssh-config-host-import.spec.ts new file mode 100644 index 00000000000..959d180ddde --- /dev/null +++ b/tests/e2e/ssh-config-host-import.spec.ts @@ -0,0 +1,202 @@ +/** + * E2E: SSH config bulk import (picker "Add all") vs Settings Import re-adopt. + * Covers plan cases P5, P6, P7, P9. Picker list/filter/select live in + * ssh-config-host-picker.spec.ts. + */ + +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { + buildSshConfigBody, + configHostRow, + expectSshHostAbsentFromSettings, + expectSshHostListedInSettings, + makeSshConfigHostPrefix, + openSshConfigHostPicker, + openSshHostSettings, + removeSshTargetByAlias, + removeSshTargetsByPrefix, + returnToAppShell, + seedIsolatedSshConfig, + seedOrcaSshTargetMatchingAlias, + type SeededSshConfigHost +} from './helpers/ssh-config-host-picker' + +// Why: afterEach deletes every target carrying this prefix; workers loading the +// module in the same millisecond must not collide on a shared Date.now(). +const HOST_PREFIX = makeSshConfigHostPrefix() + +function pairHosts(prefix: string): { alpha: SeededSshConfigHost; bravo: SeededSshConfigHost } { + return { + alpha: { + alias: `${prefix}-alpha`, + hostname: `${prefix}-alpha.example.test`, + user: 'deploy', + port: 22 + }, + bravo: { + alias: `${prefix}-bravo`, + hostname: `${prefix}-bravo.example.test`, + user: 'ops', + port: 2222 + } + } +} + +async function seedPairConfig( + electronApp: ElectronApplication, + prefix: string +): Promise<{ alpha: SeededSshConfigHost; bravo: SeededSshConfigHost }> { + const hosts = pairHosts(prefix) + await seedIsolatedSshConfig(electronApp, buildSshConfigBody([hosts.alpha, hosts.bravo])) + return hosts +} + +/** Import both config hosts via picker, then suppress `alias` (removeTarget tombstone). */ +async function importPairThenDeleteAlias( + page: Page, + electronApp: ElectronApplication, + prefix: string, + aliasToDelete: string +): Promise<{ alpha: SeededSshConfigHost; bravo: SeededSshConfigHost }> { + const hosts = await seedPairConfig(electronApp, prefix) + const picker = await openSshConfigHostPicker(page) + await expect(picker.getByRole('button', { name: 'Add all 2 to Orca' })).toBeEnabled() + await picker.getByRole('button', { name: 'Add all 2 to Orca' }).click() + await expect(page.getByText('Added 2 hosts to Orca.')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('dialog', { name: 'Choose from ~/.ssh/config' })).toBeHidden({ + timeout: 10_000 + }) + await expect(page.getByRole('dialog', { name: 'Add SSH host' })).toBeHidden({ + timeout: 10_000 + }) + await removeSshTargetByAlias(page, aliasToDelete) + return hosts +} + +test.describe('SSH config host import (bulk + settings re-adopt)', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + }) + + test.afterEach(async ({ orcaPage }) => { + await removeSshTargetsByPrefix(orcaPage, HOST_PREFIX).catch(() => undefined) + }) + + // ── P5 ───────────────────────────────────────────────────────────── + test('P5: already-in-Orca badge, disabled row, and Add all counts only new hosts', async ({ + electronApp, + orcaPage + }) => { + const hosts = await seedPairConfig(electronApp, HOST_PREFIX) + await seedOrcaSshTargetMatchingAlias(orcaPage, { + alias: hosts.alpha.alias, + hostname: hosts.alpha.hostname, + username: hosts.alpha.user, + port: hosts.alpha.port + }) + + const picker = await openSshConfigHostPicker(orcaPage) + const alphaRow = configHostRow(picker, hosts.alpha) + const bravoRow = configHostRow(picker, hosts.bravo) + + await expect(alphaRow).toBeVisible() + await expect(alphaRow).toBeDisabled() + await expect(alphaRow.getByText('In Orca', { exact: true })).toBeVisible() + + await expect(bravoRow).toBeVisible() + await expect(bravoRow).toBeEnabled() + await expect(bravoRow.getByText('In Orca', { exact: true })).toHaveCount(0) + + await expect(picker.getByRole('button', { name: 'Add all 1 to Orca' })).toBeEnabled() + await expect(picker.getByRole('button', { name: 'Add all 2 to Orca' })).toHaveCount(0) + }) + + // ── P6 ───────────────────────────────────────────────────────────── + test('P6: Add all N to Orca imports new hosts; re-open shows all in Orca', async ({ + electronApp, + orcaPage + }) => { + const hosts = await seedPairConfig(electronApp, HOST_PREFIX) + const picker = await openSshConfigHostPicker(orcaPage) + + await expect(configHostRow(picker, hosts.alpha)).toBeVisible() + await expect(configHostRow(picker, hosts.bravo)).toBeVisible() + await expect(picker.getByRole('button', { name: 'Add all 2 to Orca' })).toBeEnabled() + + await picker.getByRole('button', { name: 'Add all 2 to Orca' }).click() + await expect(orcaPage.getByText('Added 2 hosts to Orca.')).toBeVisible({ timeout: 15_000 }) + await expect(orcaPage.getByRole('dialog', { name: 'Choose from ~/.ssh/config' })).toBeHidden({ + timeout: 10_000 + }) + await expect(orcaPage.getByRole('dialog', { name: 'Add SSH host' })).toBeHidden({ + timeout: 10_000 + }) + + const sshSection = await openSshHostSettings(orcaPage) + await expectSshHostListedInSettings(sshSection, hosts.alpha) + await expectSshHostListedInSettings(sshSection, hosts.bravo) + + await returnToAppShell(orcaPage) + const reopened = await openSshConfigHostPicker(orcaPage) + await expect(configHostRow(reopened, hosts.alpha)).toBeDisabled() + await expect( + configHostRow(reopened, hosts.alpha).getByText('In Orca', { exact: true }) + ).toBeVisible() + await expect(configHostRow(reopened, hosts.bravo)).toBeDisabled() + await expect( + configHostRow(reopened, hosts.bravo).getByText('In Orca', { exact: true }) + ).toBeVisible() + await expect(reopened.getByRole('button', { name: 'All hosts already in Orca' })).toBeDisabled() + }) + + // ── P7 ───────────────────────────────────────────────────────────── + test('P7: Add all does not re-adopt deleted config hosts (suppress tombstones)', async ({ + electronApp, + orcaPage + }) => { + const hosts = await importPairThenDeleteAlias( + orcaPage, + electronApp, + HOST_PREFIX, + `${HOST_PREFIX}-alpha` + ) + + const picker = await openSshConfigHostPicker(orcaPage) + // Suppressed aliases are omitted from the picker entirely. + await expect(configHostRow(picker, hosts.alpha)).toHaveCount(0) + await expect(configHostRow(picker, hosts.bravo)).toBeVisible() + await expect( + configHostRow(picker, hosts.bravo).getByText('In Orca', { exact: true }) + ).toBeVisible() + await expect(picker.getByRole('button', { name: 'All hosts already in Orca' })).toBeDisabled() + await expect(picker.getByRole('button', { name: /Add all \d+ to Orca/ })).toHaveCount(0) + + await returnToAppShell(orcaPage) + const sshSection = await openSshHostSettings(orcaPage) + // Pane auto-syncs without reAdopt — deleted alpha must stay gone. + await expectSshHostListedInSettings(sshSection, hosts.bravo) + await expectSshHostAbsentFromSettings(sshSection, hosts.alpha) + }) + + // ── P9 ───────────────────────────────────────────────────────────── + test('P9: Settings Import re-adopts deleted config hosts', async ({ electronApp, orcaPage }) => { + const hosts = await importPairThenDeleteAlias( + orcaPage, + electronApp, + HOST_PREFIX, + `${HOST_PREFIX}-alpha` + ) + + const sshSection = await openSshHostSettings(orcaPage) + await expectSshHostListedInSettings(sshSection, hosts.bravo) + await expectSshHostAbsentFromSettings(sshSection, hosts.alpha) + + await sshSection.getByRole('button', { name: 'Import' }).click() + await expect(orcaPage.getByText(/Synced \d+ servers?/i)).toBeVisible({ timeout: 15_000 }) + + await expectSshHostListedInSettings(sshSection, hosts.alpha) + await expectSshHostListedInSettings(sshSection, hosts.bravo) + }) +}) diff --git a/tests/e2e/ssh-config-host-picker.PLAN.md b/tests/e2e/ssh-config-host-picker.PLAN.md new file mode 100644 index 00000000000..ebb7519616e --- /dev/null +++ b/tests/e2e/ssh-config-host-picker.PLAN.md @@ -0,0 +1,188 @@ +# E2E Test Plan: SSH config host picker (`import-ssh-config-does-nothing`) + +## Branch summary + +Users can open **Fill from ~/.ssh/config…** on the add-SSH-host dialog, pick a +Host alias, and get the form prefilled from `ssh -G` resolution. Bulk sync is a +secondary **Add all N to Orca** action (no re-adopt). Settings → SSH → **Import** +remains the deliberate re-adopt path. + +Commits under test (vs main): + +- `bd0b594dee` feat(ssh): add SSH config host picker for add-host form +- `dc8339369a` fix(ssh): import filter preservation and label fallback +- `5982c8108f` fix(ssh): harden config picker import, alias folding, host targeting +- `5cfb6369e7` refactor(ssh): centralize host result limit / folder group helper + +## Already covered (do **not** re-test in E2E) + +| Area | Where | +|------|--------| +| `listConfigHosts` / `resolveConfigHost` IPC registration | `src/main/ipc/ssh.test.ts` | +| Search, result limit, suppressed aliases, alreadyInOrca | `ssh-config-host-picker.test.ts` | +| Generation guard, freeze-while-resolving, late resolve | `AddRemoteHostDialog.config-picker.test.tsx` | +| Bulk `importConfig()` without `reAdopt` | `add-remote-host-ssh-actions.test.ts` | +| Alias folding / duplicate save check | `ssh-target-duplicate.test.ts` | +| `configured-only` host registry / setup fail-closed | unit tests in shared + project-host-workspace-target | +| Settings modal viewport stability | `ssh-host-form-modal.spec.ts` | + +E2E is reserved for real Electron HOME isolation, real `~/.ssh/config` parse, +real `ssh -G` resolve, and user-visible DOM outcomes. + +## Harness requirements + +1. **Isolated HOME** — E2E already sets `HOME` to `{userDataDir}/home`. Seed config with: + ```ts + const home = await electronApp.evaluate(({ app }) => app.getPath('home')) + mkdirSync(path.join(home, '.ssh'), { recursive: true, mode: 0o700 }) + writeFileSync(path.join(home, '.ssh/config'), configBody, { mode: 0o600 }) + ``` +2. **Unique aliases** — prefix Host entries and Orca labels with + `e2e-ssh-cfg-${Date.now().toString(36)}-…` so workers never collide; clean up + via `window.api.ssh.removeTarget` in `afterEach` by label/configHost prefix. +3. **Open the picker dialog** (not Settings `SshTargetForm` — that form has no + config picker). Path: + - Open **Add Project** (sidebar / landing control) + - Host combobox → **Add remote host** → **Add SSH host** + - Dialog title **Add SSH host** with link **Fill from ~/.ssh/config…** +4. **Assertions** — DOM only (`getByRole`, `toHaveValue`, visible badges/toasts). + Store/API only for setup/cleanup/seeding existing targets. +5. **Prereq** — OpenSSH client on PATH (`ssh -G`). macOS/Linux CI has it; skip + or soft-fail only if `ssh -G` is unavailable (document in test comment). + +## Spec file + +`tests/e2e/ssh-config-host-picker.spec.ts` +Reuse patterns from `ssh-host-form-modal.spec.ts` (session ready, target cleanup, +announcement dismiss). Prefer small local helpers over new shared modules unless +helpers would be reused elsewhere. + +Optional second file if the Settings Import case grows: +`tests/e2e/ssh-config-import-settings.spec.ts` — otherwise keep Import in the same file. + +--- + +## Cases (must ship) + +### P1 — Empty config empty state + +| | | +|--|--| +| **Setup** | Do not create `~/.ssh/config` (or write empty file). | +| **Steps** | Open Add SSH host → Fill from ~/.ssh/config… | +| **Expect** | Dialog title **Choose from ~/.ssh/config**; body **No hosts in ~/.ssh/config**; **Add all to Orca** disabled; **Back** returns to form. | + +### P2 — Seeded hosts listed with summary lines + +| | | +|--|--| +| **Setup** | Write config with ≥2 concrete Hosts, e.g. `e2e-alpha` / `e2e-bravo` with HostName, User, Port. | +| **Steps** | Open picker. | +| **Expect** | Host list `SSH config hosts` shows both aliases; subtitle `user@hostname:port`; button **Add all 2 to Orca** enabled. | + +### P3 — Select host prefills form (and Save persists) + +| | | +|--|--| +| **Setup** | Config Host `e2e-prod` → HostName `prod.example.test`, User `deploy`, Port `2222`. | +| **Steps** | Pick `e2e-prod` → wait for form → click **Save**. | +| **Expect** | After pick: Host/alias field = `prod.example.test`, Username `deploy`, Port `2222`, Label `e2e-prod` (or alias); optional toast *Filled from e2e-prod*; Identity file may stay empty with config hint. After Save: dialog closes; target appears in Settings → SSH (or listTargets shows matching host). | + +### P4 — Filter narrows list + +| | | +|--|--| +| **Setup** | Hosts `e2e-alpha`, `e2e-bravo`. | +| **Steps** | Open picker; filter `bravo`. | +| **Expect** | Only bravo row; alpha gone; **No matching hosts** if filter is nonsense. | + +### P5 — Already-in-Orca badge + disabled row + +| | | +|--|--| +| **Setup** | Config hosts alpha + bravo. Seed Orca target with `configHost`/`label` matching alpha (via `ssh.addTarget`). | +| **Steps** | Open picker. | +| **Expect** | Alpha shows **In Orca** badge and is not clickable; bravo still selectable; **Add all 1 to Orca** (not 2). | + +### P6 — Add all N to Orca imports new hosts only + +| | | +|--|--| +| **Setup** | Config with 2 new hosts; no Orca targets for them. | +| **Steps** | **Add all 2 to Orca** → wait for success toast / return to form or list refresh. | +| **Expect** | Both targets exist (DOM in Settings SSH and/or listTargets); re-open picker shows **All hosts already in Orca** / both **In Orca**. | + +### P7 — Add all does **not** re-adopt deleted hosts + +| | | +|--|--| +| **Setup** | Config with alpha + bravo; Add all → remove alpha via API (creates suppress tombstone). | +| **Steps** | Re-open picker; note count; optionally click Add all again. | +| **Expect** | Alpha absent from picker (suppressed) or not re-created; only new hosts counted; `listTargets` still lacks deleted alpha after second Add all. | + +### P8 — Back discards pending pick path + +| | | +|--|--| +| **Setup** | Seeded config. | +| **Steps** | Open picker → **Back** without selecting. | +| **Expect** | Form fields still empty (Host blank); no filled toast. | + +### P9 — Settings Import re-adopts (contrast with P7) + +| | | +|--|--| +| **Setup** | Same as P7 after delete. | +| **Steps** | Settings → SSH → **Import** (explicit reAdopt path). | +| **Expect** | Deleted config host reappears as an Orca target; toast sync count ≥ 1. | + +--- + +## Nice-to-have (only if cheap after P1–P9) + +- **N1** ProxyCommand / JumpHost: pick host with ProxyJump → Advanced opens and jump field filled (proves advanced prefill + `preferAdvancedOpen`). +- **N2** Case-insensitive alias: config `Prod`, existing label `prod` → **In Orca**. +- **N3** Empty Identity file hint visible after config fill. + +Skip: 100-host truncation, resolve races, GSSAPI system-default, composer host-availability fail-closed (unit-covered). + +## Out of scope + +- Real SSH connect / relay / PTY +- Docker SSH fixtures +- Web client stub paths (`listConfigHosts` returns empty) +- i18n non-English + +## Implementation status (done) + +| Case | Spec | +|------|------| +| P1 empty state | `ssh-config-host-picker.spec.ts` | +| P2 list + Add all enabled | `ssh-config-host-picker.spec.ts` | +| P3 select + Save (+ N3 identity hint) | `ssh-config-host-picker.spec.ts` | +| P4 filter | `ssh-config-host-picker.spec.ts` | +| P5 In Orca badge / count | `ssh-config-host-import.spec.ts` | +| P6 Add all imports | `ssh-config-host-import.spec.ts` | +| P7 no re-adopt after delete | `ssh-config-host-import.spec.ts` | +| P8 Back without select | `ssh-config-host-picker.spec.ts` | +| P9 Settings Import re-adopts | `ssh-config-host-import.spec.ts` | + +Shared helpers: `tests/e2e/helpers/ssh-config-host-picker.ts` + +### Product fix required for E2E (and real HOME isolation) + +OpenSSH resolves the default user config via **getpwuid**, not `$HOME`. E2E +sets an isolated `HOME`, so `loadUserSshConfig` (Node `os.homedir()`) and +`ssh -G` could disagree. `src/main/ssh/ssh-g-config-resolution.ts` now passes +`-F /.ssh/config` when the HOME config path exists and differs from the +passwd home. Normal installs (HOME = passwd home) are unchanged. + +## Suggested run command + +```bash +pnpm exec electron-vite build --mode e2e +SKIP_BUILD=1 pnpm exec playwright test \ + tests/e2e/ssh-config-host-picker.spec.ts \ + tests/e2e/ssh-config-host-import.spec.ts \ + --config tests/playwright.config.ts --project=electron-headless --workers=1 +``` diff --git a/tests/e2e/ssh-config-host-picker.spec.ts b/tests/e2e/ssh-config-host-picker.spec.ts new file mode 100644 index 00000000000..fe717762d7b --- /dev/null +++ b/tests/e2e/ssh-config-host-picker.spec.ts @@ -0,0 +1,187 @@ +/** + * E2E: Add SSH host → Fill from ~/.ssh/config picker (isolated HOME + real ssh -G). + * Plan cases: P1, P2, P3, P4, P8 (+ N3 with P3). Bulk/import: ssh-config-host-import.spec.ts. + */ + +import type { ElectronApplication } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { + addSshHostFormFields, + buildSshConfigBody, + closeOpenDialogs, + configHostRow, + expectSshHostListedInSettings, + hostEndpointSummary, + makeSshConfigHostPrefix, + openSshConfigHostPicker, + openSshHostSettings, + removeSshTargetsByPrefix, + seedIsolatedSshConfig, + type SeededSshConfigHost +} from './helpers/ssh-config-host-picker' + +// Why: afterEach deletes every target carrying this prefix; workers loading the +// module in the same millisecond must not collide on a shared Date.now(). +const HOST_PREFIX = makeSshConfigHostPrefix() + +function pairHosts(prefix: string): { alpha: SeededSshConfigHost; bravo: SeededSshConfigHost } { + return { + alpha: { + alias: `${prefix}-alpha`, + hostname: `${prefix}-alpha.example.test`, + user: 'deploy', + port: 22 + }, + bravo: { + alias: `${prefix}-bravo`, + hostname: `${prefix}-bravo.example.test`, + user: 'alice', + port: 2222 + } + } +} + +async function seedPairConfig( + electronApp: ElectronApplication, + prefix: string +): Promise<{ alpha: SeededSshConfigHost; bravo: SeededSshConfigHost }> { + const hosts = pairHosts(prefix) + await seedIsolatedSshConfig(electronApp, buildSshConfigBody([hosts.alpha, hosts.bravo])) + return hosts +} + +test.describe('SSH config host picker', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + }) + + test.afterEach(async ({ orcaPage }) => { + await closeOpenDialogs(orcaPage).catch(() => undefined) + await removeSshTargetsByPrefix(orcaPage, HOST_PREFIX).catch(() => undefined) + }) + + // ── P1 ───────────────────────────────────────────────────────────── + test('P1: empty config shows empty state; Back returns to blank form', async ({ orcaPage }) => { + // Isolated HOME has no ~/.ssh/config by default. + const picker = await openSshConfigHostPicker(orcaPage) + await expect(picker.getByRole('heading', { name: 'Choose from ~/.ssh/config' })).toBeVisible() + await expect(picker.getByText('No hosts in ~/.ssh/config')).toBeVisible() + await expect( + picker.getByText('Add a Host entry there, or go back and type the details manually.') + ).toBeVisible() + await expect(picker.getByRole('button', { name: 'Add all to Orca' })).toBeDisabled() + + await picker.getByRole('button', { name: 'Back' }).click() + const form = orcaPage.getByRole('dialog', { name: 'Add SSH host' }) + await expect(form.getByRole('heading', { name: 'Add SSH host' })).toBeVisible() + const fields = addSshHostFormFields(form) + await expect(fields.host).toHaveValue('') + await expect(fields.username).toHaveValue('') + await expect(fields.label).toHaveValue('') + }) + + // ── P2 ───────────────────────────────────────────────────────────── + test('P2: seeded hosts list with summary lines and Add all enabled', async ({ + electronApp, + orcaPage + }) => { + const hosts = await seedPairConfig(electronApp, HOST_PREFIX) + const picker = await openSshConfigHostPicker(orcaPage) + + const hostList = picker.getByRole('list', { name: 'SSH config hosts' }) + await expect(hostList).toBeVisible() + await expect(configHostRow(picker, hosts.alpha)).toBeVisible() + await expect(configHostRow(picker, hosts.bravo)).toBeVisible() + await expect( + hostList.getByText(hostEndpointSummary(hosts.alpha), { exact: true }) + ).toBeVisible() + await expect( + hostList.getByText(hostEndpointSummary(hosts.bravo), { exact: true }) + ).toBeVisible() + await expect(picker.getByRole('button', { name: 'Add all 2 to Orca' })).toBeEnabled() + }) + + // ── P3 + N3 ──────────────────────────────────────────────────────── + test('P3: select host prefills form; Save persists; N3 identity hint', async ({ + electronApp, + orcaPage + }) => { + const prod: SeededSshConfigHost = { + alias: `${HOST_PREFIX}-prod`, + hostname: 'prod.example.test', + user: 'deploy', + port: 2222 + } + await seedIsolatedSshConfig(electronApp, buildSshConfigBody([prod])) + + const picker = await openSshConfigHostPicker(orcaPage) + await configHostRow(picker, prod).click() + + const form = orcaPage.getByRole('dialog', { name: 'Add SSH host' }) + await expect(form.getByRole('heading', { name: 'Add SSH host' })).toBeVisible({ + timeout: 10_000 + }) + const fields = addSshHostFormFields(form) + await expect(fields.host).toHaveValue(prod.hostname, { timeout: 15_000 }) + await expect(fields.username).toHaveValue(prod.user) + await expect(fields.port).toHaveValue(String(prod.port)) + await expect(fields.label).toHaveValue(prod.alias) + await expect(fields.identityFile).toHaveValue('') + // N3: empty Identity file explains multi-key resolve from config. + await expect( + form.getByText(new RegExp(`Left empty on purpose:.*${escapeRegExp(prod.alias)}`, 'i')) + ).toBeVisible() + await expect( + orcaPage.getByText(new RegExp(`Filled from ${escapeRegExp(prod.alias)}`, 'i')) + ).toBeVisible({ timeout: 5_000 }) + + await form.getByRole('button', { name: 'Save' }).click() + await expect(form).toBeHidden({ timeout: 10_000 }) + await expect(orcaPage.getByText('SSH host added.')).toBeVisible({ timeout: 5_000 }) + + const sshSection = await openSshHostSettings(orcaPage) + await expectSshHostListedInSettings(sshSection, prod) + }) + + // ── P4 ───────────────────────────────────────────────────────────── + test('P4: filter narrows host list', async ({ electronApp, orcaPage }) => { + const hosts = await seedPairConfig(electronApp, HOST_PREFIX) + const picker = await openSshConfigHostPicker(orcaPage) + + await expect(configHostRow(picker, hosts.alpha)).toBeVisible() + await expect(configHostRow(picker, hosts.bravo)).toBeVisible() + + const filter = picker.getByRole('textbox', { name: 'Filter hosts…' }) + await filter.fill('bravo') + // Why: picker debounces filter IPC ~200ms. + await expect(configHostRow(picker, hosts.bravo)).toBeVisible({ timeout: 5_000 }) + await expect(configHostRow(picker, hosts.alpha)).toHaveCount(0) + + await filter.fill('no-such-host-zzzz') + await expect(picker.getByText('No matching hosts')).toBeVisible({ timeout: 5_000 }) + await expect( + picker.getByText('Try another filter, or go back and type manually.') + ).toBeVisible() + }) + + // ── P8 ───────────────────────────────────────────────────────────── + test('P8: Back without select leaves form empty', async ({ electronApp, orcaPage }) => { + const hosts = await seedPairConfig(electronApp, HOST_PREFIX) + const picker = await openSshConfigHostPicker(orcaPage) + await expect(configHostRow(picker, hosts.alpha)).toBeVisible() + + await picker.getByRole('button', { name: 'Back' }).click() + const form = orcaPage.getByRole('dialog', { name: 'Add SSH host' }) + await expect(form.getByRole('heading', { name: 'Add SSH host' })).toBeVisible() + const fields = addSshHostFormFields(form) + await expect(fields.host).toHaveValue('') + await expect(fields.username).toHaveValue('') + await expect(fields.label).toHaveValue('') + await expect(orcaPage.getByText(/Filled from /i)).toHaveCount(0) + }) +}) + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} From 0586bab4f90f381f51e568c260fd45abface811e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:55:30 -0700 Subject: [PATCH 007/101] fix(mobile): bound terminal viewport resubscribe loop with backoff (STA-3337) (#12362) * fix(mobile): bound terminal viewport resubscribe loop with backoff (STA-3337) An empty scrollback frame with absent host dims was coerced to 80x24, which never equals a phone viewport, arming a zero-delay unsubscribe/resubscribe loop (~25/s) that broke long-press gestures and drained battery. - Absent host dims now hold the stream instead of resubscribing. - Fit resubscribes are budgeted per handle (3 attempts, escalating backoff) with an absence-gated refill mirroring the chat-side rearm bound; on exhaustion the view degrades visibly via toast instead of hot-looping. - A fresh post-measure match counts as convergence instead of resubscribing. - setTerminalModes keeps the Map identity when the mode is unchanged, so same-mode frames no longer re-render the session route. - Host emits the subscriber viewport as scrollback dims when the snapshot and PTY size are both unavailable, so current hosts converge immediately. * fix(mobile): cancel stale viewport retries after convergence --- .../app/h/[hostId]/session/[worktreeId].tsx | 95 ++-- .../session/mobile-terminal-diagnostics.ts | 27 +- ...bile-terminal-viewport-resubscribe.test.ts | 472 ++++++++++++++++++ .../mobile-terminal-viewport-resubscribe.ts | 293 +++++++++++ src/main/runtime/rpc/methods/terminal.ts | 12 +- 5 files changed, 850 insertions(+), 49 deletions(-) create mode 100644 mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts create mode 100644 mobile/src/session/mobile-terminal-viewport-resubscribe.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8f6d7ca0c97..4f190c08d0f 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -235,6 +235,11 @@ import { } from '../../../../src/session/mobile-terminal-prune-decision' import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream' import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' +import { + TerminalViewportResubscribeBudget, + readTerminalViewportDims, + runTerminalViewportFitPass +} from '../../../../src/session/mobile-terminal-viewport-resubscribe' import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation' import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics' import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects' @@ -1030,6 +1035,8 @@ export default function SessionScreen() { const subscribingHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics()) + // Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337). + const viewportResubscribeBudgetRef = useRef(new TerminalViewportResubscribeBudget()) // Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered. const webReadyHandlesRef = useRef>(new Set()) const activeHandleRef = useRef(null) @@ -1376,6 +1383,7 @@ export default function SessionScreen() { subscribingHandlesRef.current.clear() initializedHandlesRef.current.clear() terminalDiagnosticsRef.current.clearTerminalCache() + viewportResubscribeBudgetRef.current.clear() webReadyHandlesRef.current.clear() subscribeSeqRef.current.clear() layoutSeqRef.current.clear() @@ -1505,10 +1513,11 @@ export default function SessionScreen() { return } updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) - const cols = (data.cols as number) || 80 - const rows = (data.rows as number) || 24 - const scrollbackCols = cols - const scrollbackRows = rows + const { hostCols, hostRows } = readTerminalViewportDims(data) + // Why: absent host dims must not be coerced into a comparable size — 80x24 + // never equals a phone viewport and armed a zero-delay resubscribe loop (STA-3337). + const cols = hostCols ?? viewportRef.current?.cols ?? 80 + const rows = hostRows ?? viewportRef.current?.rows ?? 24 const initialData = typeof data.serialized === 'string' && data.serialized.length > 0 ? data.serialized @@ -1527,46 +1536,36 @@ export default function SessionScreen() { ref.init(cols, rows, initialData, false, oscLinks) initializedHandlesRef.current.add(handle) if (data.displayMode) { + const displayMode = data.displayMode as MobileDisplayMode + // Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route. setTerminalModes((prev) => - new Map(prev).set(handle, data.displayMode as MobileDisplayMode) + prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode) ) } // Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM. scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) - // Why: first subscribe has no viewport (xterm not loaded yet), so measure after init and resubscribe so the server can phone-fit. - const needsResubscribe = - !viewportMeasuredRef.current || - (viewportRef.current != null && - (scrollbackCols !== viewportRef.current.cols || - scrollbackRows !== viewportRef.current.rows)) - if (needsResubscribe) { - void (async () => { - // Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06). - await getTerminalRef(handle)?.awaitReady() - if (subscribeSeqRef.current.get(handle) !== seq) { - return - } - const dims = await getTerminalRef(handle)?.measureFitDimensions( - terminalFrameHeightRef.current || undefined - ) - // Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation. - if (subscribeSeqRef.current.get(handle) !== seq) { - return - } - if (!getTerminalRef(handle)) { - return - } - // Why: scrollback came back at cols=80 (server's null-viewport fallback), so this subscriber record has no viewport — resubscribe so the server stores it. - if (dims) { - diagnostics.streamResubscribing(handle, seq, dims) - viewportRef.current = dims - viewportMeasuredRef.current = true - unsubscribeTerminal(handle) - initializedHandlesRef.current.delete(handle) - subscribeToTerminal(handle) - } - })() - } + // Why: first subscribe has no viewport (xterm not loaded yet), so measure after init + // and resubscribe so the server can phone-fit — bounded per handle so a + // non-converging host degrades visibly instead of hot-looping (STA-3337). + runTerminalViewportFitPass({ + handle, + seq, + hostCols, + hostRows, + budget: viewportResubscribeBudgetRef.current, + diagnostics, + viewportRef, + viewportMeasuredRef, + subscribeSeqRef, + initializedHandlesRef, + terminalUnsubsRef, + terminalFrameHeightRef, + getTerminalRef, + unsubscribeTerminal, + subscribeToTerminal, + scheduleDelayedAction, + showToast + }) } else if (data.type === 'metadata') { updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) } else if (data.type === 'data') { @@ -1591,8 +1590,12 @@ export default function SessionScreen() { } else if (data.type === 'resized') { updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) // Server resize: reinit xterm on a full-buffer snapshot (width reflow rewraps scrollback), else just resize geometry. - const cols = (data.cols as number) || 80 - const rows = (data.rows as number) || 24 + const viewport = viewportMeasuredRef.current ? viewportRef.current : null + const [cols, rows] = viewportResubscribeBudgetRef.current.observeResize( + handle, + data, + viewport + ) const serialized = typeof data.serialized === 'string' ? data.serialized : null diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null) const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined @@ -1602,8 +1605,10 @@ export default function SessionScreen() { getTerminalRef(handle)?.resize(cols, rows) } if (data.displayMode) { + const displayMode = data.displayMode as MobileDisplayMode + // Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route. setTerminalModes((prev) => - new Map(prev).set(handle, data.displayMode as MobileDisplayMode) + prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode) ) } scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) @@ -1619,7 +1624,7 @@ export default function SessionScreen() { } subscribingHandlesRef.current.delete(handle) }, - [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction] + [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast] ) const nativeChatStream = useMobileNativeChatTerminalStream({ @@ -1720,12 +1725,16 @@ export default function SessionScreen() { unsubscribeTerminal(handle) terminalRefs.current.delete(handle) initializedHandlesRef.current.delete(handle) + viewportResubscribeBudgetRef.current.forget(handle) clearTerminalLiveInputDefault(handle) } setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune)) // Why: a chat-covered handle the host reports again refills its rearm budget, // so an exhausted rearm can't lock the composer until leave-chat. nativeChatStream.notifyListedHandles(liveHandles) + // Why: same absence-gated refill for the viewport-fit budget — a handle that + // left the list and returned may converge now, so it earns fresh attempts. + viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles) lastKnownTerminalCountRef.current = result.terminals.length // Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order. const seen = new Set() diff --git a/mobile/src/session/mobile-terminal-diagnostics.ts b/mobile/src/session/mobile-terminal-diagnostics.ts index 8695928ed03..6ee636fb9f4 100644 --- a/mobile/src/session/mobile-terminal-diagnostics.ts +++ b/mobile/src/session/mobile-terminal-diagnostics.ts @@ -141,12 +141,35 @@ export class MobileTerminalDiagnostics { }) } - streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void { + streamResubscribing( + handle: string, + seq: number, + dims: { cols: number; rows: number }, + attempt: number, + delayMs: number + ): void { logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', { handle: shortenMobileTerminalDiagnosticId(handle), seq, cols: dims.cols, - rows: dims.rows + rows: dims.rows, + attempt, + delayMs + }) + } + + streamResubscribeHeld(handle: string, seq: number): void { + logMobileTerminalDiagnostic('stream-resubscribe-held-absent-dims', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq + }) + } + + streamResubscribeExhausted(handle: string, seq: number, attempts: number): void { + logMobileTerminalDiagnostic('stream-resubscribe-exhausted', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + attempts }) } diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts new file mode 100644 index 00000000000..35aac89f6f8 --- /dev/null +++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts @@ -0,0 +1,472 @@ +import { describe, expect, it, vi } from 'vitest' +import { + MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS, + TerminalViewportResubscribeBudget, + readTerminalViewportDims, + resolveTerminalViewportResubscribe, + runTerminalViewportFitPass, + shouldResubscribeAfterViewportMeasure, + type TerminalViewportFitPassArgs +} from './mobile-terminal-viewport-resubscribe' + +const PHONE = { cols: 40, rows: 50 } + +describe('readTerminalViewportDims', () => { + it('accepts only usable numeric host dimensions', () => { + expect(readTerminalViewportDims({ cols: 40, rows: 50 })).toEqual({ + hostCols: 40, + hostRows: 50 + }) + expect(readTerminalViewportDims({ cols: Number.NaN, rows: 0 })).toEqual({ + hostCols: null, + hostRows: null + }) + }) +}) + +describe('resolveTerminalViewportResubscribe', () => { + it('resubscribes immediately on the first pass when the viewport is unmeasured', () => { + expect( + resolveTerminalViewportResubscribe({ + hostCols: 80, + hostRows: 24, + viewportMeasured: false, + viewport: null, + attempts: 0 + }) + ).toEqual({ kind: 'resubscribe', delayMs: 0 }) + }) + + it('caps even the unmeasured pass once the budget is spent', () => { + expect( + resolveTerminalViewportResubscribe({ + hostCols: 80, + hostRows: 24, + viewportMeasured: false, + viewport: null, + attempts: MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS + }) + ).toEqual({ kind: 'exhausted' }) + }) + + it('holds on absent host dims instead of resubscribing (STA-3337 regression)', () => { + for (const [hostCols, hostRows] of [ + [null, null], + [80, null], + [null, 24] + ] as const) { + expect( + resolveTerminalViewportResubscribe({ + hostCols, + hostRows, + viewportMeasured: true, + viewport: PHONE, + attempts: 0 + }) + ).toEqual({ kind: 'hold' }) + } + }) + + it('keeps holding on absent dims across repeated frames without spending budget', () => { + for (let frame = 0; frame < 50; frame += 1) { + expect( + resolveTerminalViewportResubscribe({ + hostCols: null, + hostRows: null, + viewportMeasured: true, + viewport: PHONE, + attempts: 0 + }).kind + ).toBe('hold') + } + }) + + it('converges when host dims match the measured viewport', () => { + expect( + resolveTerminalViewportResubscribe({ + hostCols: PHONE.cols, + hostRows: PHONE.rows, + viewportMeasured: true, + viewport: PHONE, + attempts: 2 + }) + ).toEqual({ kind: 'converged' }) + }) + + it('backs off across mismatch attempts and then exhausts', () => { + const delays = [0, 1, 2].map((attempts) => { + const decision = resolveTerminalViewportResubscribe({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + attempts + }) + if (decision.kind !== 'resubscribe') { + throw new Error(`expected resubscribe at attempt ${attempts}, got ${decision.kind}`) + } + return decision.delayMs + }) + expect(delays[0]).toBe(0) + expect(delays[1]).toBeGreaterThan(0) + expect(delays[2]).toBeGreaterThan(delays[1]) + expect( + resolveTerminalViewportResubscribe({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + attempts: MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS + }) + ).toEqual({ kind: 'exhausted' }) + }) +}) + +describe('shouldResubscribeAfterViewportMeasure', () => { + it('always resubscribes when the viewport was never measured (server must learn it)', () => { + expect( + shouldResubscribeAfterViewportMeasure({ + hostCols: PHONE.cols, + hostRows: PHONE.rows, + measured: PHONE, + viewportWasMeasured: false + }) + ).toBe(true) + }) + + it('skips the resubscribe when the fresh measure already matches the host', () => { + expect( + shouldResubscribeAfterViewportMeasure({ + hostCols: PHONE.cols, + hostRows: PHONE.rows, + measured: PHONE, + viewportWasMeasured: true + }) + ).toBe(false) + }) + + it('resubscribes when the host still disagrees with the fresh measure', () => { + expect( + shouldResubscribeAfterViewportMeasure({ + hostCols: 80, + hostRows: 24, + measured: PHONE, + viewportWasMeasured: true + }) + ).toBe(true) + }) +}) + +describe('TerminalViewportResubscribeBudget', () => { + const exhaust = (budget: TerminalViewportResubscribeBudget, handle: string) => { + for (let i = 0; i < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS; i += 1) { + budget.chargeAttempt(handle) + } + } + + it('starts at zero and counts charged attempts per handle', () => { + const budget = new TerminalViewportResubscribeBudget() + expect(budget.attempts('t1')).toBe(0) + budget.chargeAttempt('t1') + budget.chargeAttempt('t1') + expect(budget.attempts('t1')).toBe(2) + expect(budget.attempts('t2')).toBe(0) + }) + + it('resets attempts and re-arms the announcement on convergence', () => { + const budget = new TerminalViewportResubscribeBudget() + exhaust(budget, 't1') + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + budget.markConverged('t1') + expect(budget.attempts('t1')).toBe(0) + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + }) + + it('announces exhaustion exactly once', () => { + const budget = new TerminalViewportResubscribeBudget() + exhaust(budget, 't1') + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + expect(budget.shouldAnnounceExhaustion('t1')).toBe(false) + }) + + it('does not refill an exhausted handle that stayed listed', () => { + const budget = new TerminalViewportResubscribeBudget() + exhaust(budget, 't1') + for (let refresh = 0; refresh < 5; refresh += 1) { + budget.notifyListedHandles(new Set(['t1'])) + } + expect(budget.attempts('t1')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS) + }) + + it('refills only after the exhausted handle went absent and came back', () => { + const budget = new TerminalViewportResubscribeBudget() + exhaust(budget, 't1') + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + budget.notifyListedHandles(new Set()) + // Still exhausted while absent — the refill lands on the return. + expect(budget.attempts('t1')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS) + budget.notifyListedHandles(new Set(['t1'])) + expect(budget.attempts('t1')).toBe(0) + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + }) + + it('leaves below-cap handles untouched by list refreshes', () => { + const budget = new TerminalViewportResubscribeBudget() + budget.chargeAttempt('t1') + budget.notifyListedHandles(new Set()) + budget.notifyListedHandles(new Set(['t1'])) + expect(budget.attempts('t1')).toBe(1) + }) + + it('forget clears one handle, clear clears everything', () => { + const budget = new TerminalViewportResubscribeBudget() + exhaust(budget, 't1') + exhaust(budget, 't2') + budget.forget('t1') + expect(budget.attempts('t1')).toBe(0) + expect(budget.attempts('t2')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS) + budget.clear() + expect(budget.attempts('t2')).toBe(0) + }) +}) + +describe('runTerminalViewportFitPass', () => { + const HANDLE = 't1' + + function makeHarness(overrides: { + hostCols?: number | null + hostRows?: number | null + viewportMeasured?: boolean + viewport?: { cols: number; rows: number } | null + measured?: { cols: number; rows: number } | null + budget?: TerminalViewportResubscribeBudget + }) { + const budget = overrides.budget ?? new TerminalViewportResubscribeBudget() + const diagnostics = { + streamResubscribing: vi.fn(), + streamResubscribeHeld: vi.fn(), + streamResubscribeExhausted: vi.fn() + } + const terminalUnsubsRef = { current: new Map void>([[HANDLE, () => {}]]) } + const scheduled: { fn: () => void; ms: number }[] = [] + const webView = { + awaitReady: () => Promise.resolve(), + measureFitDimensions: () => Promise.resolve(overrides.measured ?? PHONE) + } + const unsubscribeTerminal = vi.fn((handle: string) => { + terminalUnsubsRef.current.delete(handle) + }) + // Mimic the real subscribe path: arming registers an unsubscribe handle. + const subscribeToTerminal = vi.fn((handle: string) => { + terminalUnsubsRef.current.set(handle, () => {}) + }) + const showToast = vi.fn() + const args: TerminalViewportFitPassArgs = { + handle: HANDLE, + seq: 1, + hostCols: overrides.hostCols ?? null, + hostRows: overrides.hostRows ?? null, + budget, + diagnostics, + viewportRef: { current: overrides.viewport ?? null }, + viewportMeasuredRef: { current: overrides.viewportMeasured ?? false }, + subscribeSeqRef: { current: new Map([[HANDLE, 1]]) }, + initializedHandlesRef: { current: new Set([HANDLE]) }, + terminalUnsubsRef, + terminalFrameHeightRef: { current: 0 }, + getTerminalRef: () => webView, + unsubscribeTerminal, + subscribeToTerminal, + scheduleDelayedAction: (fn, ms) => scheduled.push({ fn, ms }), + showToast + } + return { + args, + budget, + diagnostics, + scheduled, + subscribeToTerminal, + unsubscribeTerminal, + showToast + } + } + + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + + it('holds without any teardown when host dims are absent (STA-3337 regression)', async () => { + const h = makeHarness({ viewportMeasured: true, viewport: PHONE }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.unsubscribeTerminal).not.toHaveBeenCalled() + expect(h.subscribeToTerminal).not.toHaveBeenCalled() + expect(h.diagnostics.streamResubscribeHeld).toHaveBeenCalledTimes(1) + }) + + it('measures and resubscribes immediately on the first pass, charging one attempt', async () => { + const h = makeHarness({ hostCols: 80, hostRows: 24 }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.unsubscribeTerminal).toHaveBeenCalledTimes(1) + expect(h.subscribeToTerminal).toHaveBeenCalledTimes(1) + expect(h.args.viewportRef.current).toEqual(PHONE) + expect(h.args.viewportMeasuredRef.current).toBe(true) + expect(h.budget.attempts(HANDLE)).toBe(1) + }) + + it('treats an equal fresh measure as convergence instead of resubscribing', async () => { + const budget = new TerminalViewportResubscribeBudget() + budget.chargeAttempt(HANDLE) + // Stale cached viewport disagrees with the host, but the fresh measure matches it. + const h = makeHarness({ + hostCols: PHONE.cols, + hostRows: PHONE.rows, + viewportMeasured: true, + viewport: { cols: 30, rows: 20 }, + measured: PHONE, + budget + }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.subscribeToTerminal).not.toHaveBeenCalled() + expect(h.budget.attempts(HANDLE)).toBe(0) + }) + + it('defers later attempts through the backoff scheduler while keeping the stream up', async () => { + const budget = new TerminalViewportResubscribeBudget() + budget.chargeAttempt(HANDLE) + const h = makeHarness({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + budget + }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.scheduled).toHaveLength(1) + expect(h.scheduled[0].ms).toBeGreaterThan(0) + // The stream must still be up until the deferred retry fires. + expect(h.unsubscribeTerminal).not.toHaveBeenCalled() + h.scheduled[0].fn() + expect(h.unsubscribeTerminal).toHaveBeenCalledTimes(1) + expect(h.subscribeToTerminal).toHaveBeenCalledTimes(1) + expect(h.budget.attempts(HANDLE)).toBe(2) + }) + + it('drops a deferred retry whose subscribe generation went stale', async () => { + const budget = new TerminalViewportResubscribeBudget() + budget.chargeAttempt(HANDLE) + const h = makeHarness({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + budget + }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.scheduled).toHaveLength(1) + h.args.subscribeSeqRef.current.set(HANDLE, 2) + h.scheduled[0].fn() + expect(h.unsubscribeTerminal).not.toHaveBeenCalled() + expect(h.budget.attempts(HANDLE)).toBe(1) + }) + + it('drops a deferred retry after the live stream converges', async () => { + const budget = new TerminalViewportResubscribeBudget() + budget.chargeAttempt(HANDLE) + const h = makeHarness({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + budget + }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.scheduled).toHaveLength(1) + expect(h.budget.observeResize(HANDLE, PHONE, PHONE)).toEqual([PHONE.cols, PHONE.rows]) + h.scheduled[0].fn() + expect(h.unsubscribeTerminal).not.toHaveBeenCalled() + expect(h.subscribeToTerminal).not.toHaveBeenCalled() + expect(h.budget.attempts(HANDLE)).toBe(0) + }) + + it('announces exhaustion once and stops touching the stream', async () => { + const budget = new TerminalViewportResubscribeBudget() + for (let i = 0; i < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS; i += 1) { + budget.chargeAttempt(HANDLE) + } + const h = makeHarness({ + hostCols: 80, + hostRows: 24, + viewportMeasured: true, + viewport: PHONE, + budget + }) + runTerminalViewportFitPass(h.args) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.unsubscribeTerminal).not.toHaveBeenCalled() + expect(h.subscribeToTerminal).not.toHaveBeenCalled() + expect(h.showToast).toHaveBeenCalledTimes(1) + expect(h.diagnostics.streamResubscribeExhausted).toHaveBeenCalledTimes(2) + }) +}) + +describe('STA-3337 stream shapes', () => { + it('empty scrollback with absent dims settles after a single register pass', () => { + const budget = new TerminalViewportResubscribeBudget() + // Pass 1: no viewport yet — measure and resubscribe so the server learns it. + const first = resolveTerminalViewportResubscribe({ + hostCols: null, + hostRows: null, + viewportMeasured: false, + viewport: null, + attempts: budget.attempts('t1') + }) + expect(first).toEqual({ kind: 'resubscribe', delayMs: 0 }) + budget.chargeAttempt('t1') + // Pass 2+: host still reports no dims — the stream must be left alone. + for (let frame = 0; frame < 10; frame += 1) { + expect( + resolveTerminalViewportResubscribe({ + hostCols: null, + hostRows: null, + viewportMeasured: true, + viewport: PHONE, + attempts: budget.attempts('t1') + }).kind + ).toBe('hold') + } + expect(budget.attempts('t1')).toBe(1) + }) + + it('non-converging numeric dims degrade after the bounded backoff run', () => { + const budget = new TerminalViewportResubscribeBudget() + const kinds: string[] = [] + for (let frame = 0; frame < 6; frame += 1) { + const decision = resolveTerminalViewportResubscribe({ + hostCols: 80, + hostRows: 24, + viewportMeasured: frame > 0, + viewport: frame > 0 ? PHONE : null, + attempts: budget.attempts('t1') + }) + kinds.push(decision.kind) + if (decision.kind === 'resubscribe') { + budget.chargeAttempt('t1') + } + } + expect(kinds).toEqual([ + 'resubscribe', + 'resubscribe', + 'resubscribe', + 'exhausted', + 'exhausted', + 'exhausted' + ]) + expect(budget.shouldAnnounceExhaustion('t1')).toBe(true) + expect(budget.shouldAnnounceExhaustion('t1')).toBe(false) + }) +}) diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts new file mode 100644 index 00000000000..8ad3c99c13e --- /dev/null +++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts @@ -0,0 +1,293 @@ +/** Bounds the scrollback→measure→resubscribe fit loop (STA-3337): a host whose + * frame dims can never equal the phone viewport must not re-arm the stream + * forever — it broke gesture recognition and drained battery at ~25 cycles/s. */ + +import type { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics' + +export const MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS = 3 + +/** Attempt-indexed teardown delay. Attempt 0 is the ordinary first fit pass + * (server learns the viewport) and must stay immediate; later attempts mean + * the server answered with non-matching dims, so probe at a decaying rate. */ +const TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS = [0, 750, 3000] as const + +export type TerminalViewportDims = { readonly cols: number; readonly rows: number } + +function readPositiveDimension(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +export function readTerminalViewportDims(data: Readonly>): { + readonly hostCols: number | null + readonly hostRows: number | null +} { + return { + hostCols: readPositiveDimension(data.cols), + hostRows: readPositiveDimension(data.rows) + } +} + +export type TerminalViewportResubscribeDecision = + | { readonly kind: 'resubscribe'; readonly delayMs: number } + | { readonly kind: 'converged' } + | { readonly kind: 'hold' } + | { readonly kind: 'exhausted' } + +function resubscribeDelayMs(attempts: number): number { + return TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS[ + Math.min(attempts, TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS.length - 1) + ] +} + +export function resolveTerminalViewportResubscribe(args: { + hostCols: number | null + hostRows: number | null + viewportMeasured: boolean + viewport: TerminalViewportDims | null + attempts: number +}): TerminalViewportResubscribeDecision { + const overBudget = args.attempts >= MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS + // First subscribe carries no viewport; resubscribing is how the server learns it. + if (!args.viewportMeasured || args.viewport == null) { + return overBudget ? { kind: 'exhausted' } : { kind: 'resubscribe', delayMs: 0 } + } + // Why: a host that doesn't report PTY dims can never converge — resubscribing + // replays the identical frame, so keep the stream instead of probing it. + if (args.hostCols == null || args.hostRows == null) { + return { kind: 'hold' } + } + if (args.hostCols === args.viewport.cols && args.hostRows === args.viewport.rows) { + return { kind: 'converged' } + } + return overBudget + ? { kind: 'exhausted' } + : { kind: 'resubscribe', delayMs: resubscribeDelayMs(args.attempts) } +} + +/** Post-measure re-check: the pre-measure mismatch may have been a stale cached + * viewport. Resubscribing is only productive when the server still disagrees + * with the fresh measure, or was never told the viewport at all. */ +export function shouldResubscribeAfterViewportMeasure(args: { + hostCols: number | null + hostRows: number | null + measured: TerminalViewportDims + viewportWasMeasured: boolean +}): boolean { + if (!args.viewportWasMeasured) { + return true + } + return args.hostCols !== args.measured.cols || args.hostRows !== args.measured.rows +} + +/** Per-handle resubscribe budget, mirroring the chat-side rearm bound: attempts + * refill only when the handle actually left terminal.list and came back. A + * still-listed non-converging handle re-funded on every list refresh would undo + * the bound this class exists to enforce. */ +export class TerminalViewportResubscribeBudget { + private readonly attemptsByHandle = new Map() + private readonly absentSinceExhaustion = new Set() + private readonly announcedExhaustion = new Set() + private readonly retryGenerationByHandle = new Map() + + attempts(handle: string): number { + return this.attemptsByHandle.get(handle) ?? 0 + } + + chargeAttempt(handle: string): void { + this.attemptsByHandle.set(handle, this.attempts(handle) + 1) + } + + retryGeneration(handle: string): object { + const existing = this.retryGenerationByHandle.get(handle) + if (existing) { + return existing + } + const generation = {} + this.retryGenerationByHandle.set(handle, generation) + return generation + } + + isRetryGenerationCurrent(handle: string, generation: object): boolean { + return this.retryGenerationByHandle.get(handle) === generation + } + + observeResize( + handle: string, + data: Readonly>, + viewport: TerminalViewportDims | null + ): readonly [number, number] { + const { hostCols, hostRows } = readTerminalViewportDims(data) + const cols = hostCols ?? 80 + const rows = hostRows ?? 24 + if (viewport?.cols === cols && viewport.rows === rows) { + this.markConverged(handle) + } + return [cols, rows] + } + + markConverged(handle: string): void { + this.forget(handle) + } + + /** True exactly once per exhaustion so the degraded state is announced, not spammed. */ + shouldAnnounceExhaustion(handle: string): boolean { + if (this.announcedExhaustion.has(handle)) { + return false + } + this.announcedExhaustion.add(handle) + return true + } + + notifyListedHandles(liveHandles: ReadonlySet): void { + for (const handle of Array.from(this.attemptsByHandle.keys())) { + if (this.attempts(handle) < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS) { + continue + } + if (!liveHandles.has(handle)) { + this.absentSinceExhaustion.add(handle) + continue + } + // Why: only an absence marker buys a refill — the handle's PTY may be live + // again, so a fresh budget (and a fresh degrade announcement) is warranted. + if (this.absentSinceExhaustion.delete(handle)) { + this.forget(handle) + } + } + } + + forget(handle: string): void { + this.attemptsByHandle.delete(handle) + this.absentSinceExhaustion.delete(handle) + this.announcedExhaustion.delete(handle) + this.retryGenerationByHandle.delete(handle) + } + + clear(): void { + this.attemptsByHandle.clear() + this.absentSinceExhaustion.clear() + this.announcedExhaustion.clear() + this.retryGenerationByHandle.clear() + } +} + +type MutableRef = { current: T } + +type TerminalFitWebView = { + awaitReady: () => Promise + measureFitDimensions: (frameHeight?: number) => Promise +} + +export type TerminalViewportFitPassArgs = { + handle: string + seq: number + hostCols: number | null + hostRows: number | null + budget: TerminalViewportResubscribeBudget + diagnostics: Pick< + MobileTerminalDiagnostics, + 'streamResubscribing' | 'streamResubscribeHeld' | 'streamResubscribeExhausted' + > + viewportRef: MutableRef + viewportMeasuredRef: MutableRef + subscribeSeqRef: MutableRef> + initializedHandlesRef: MutableRef> + terminalUnsubsRef: MutableRef void>> + terminalFrameHeightRef: MutableRef + getTerminalRef: (handle: string | null) => TerminalFitWebView | undefined + unsubscribeTerminal: (handle: string) => void + subscribeToTerminal: (handle: string) => void + scheduleDelayedAction: (fn: () => void, ms: number) => void + showToast: (message: string, durationMs?: number) => void +} + +/** One bounded fit pass per scrollback frame: converge, hold, degrade visibly, + * or measure and resubscribe (backing off) so the server can phone-fit. */ +export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): void { + const { handle, seq, hostCols, hostRows, budget, diagnostics } = args + const retryGeneration = budget.retryGeneration(handle) + const decision = resolveTerminalViewportResubscribe({ + hostCols, + hostRows, + viewportMeasured: args.viewportMeasuredRef.current, + viewport: args.viewportRef.current, + attempts: budget.attempts(handle) + }) + if (decision.kind === 'converged') { + budget.markConverged(handle) + return + } + if (decision.kind === 'hold') { + diagnostics.streamResubscribeHeld(handle, seq) + return + } + if (decision.kind === 'exhausted') { + diagnostics.streamResubscribeExhausted(handle, seq, budget.attempts(handle)) + if (budget.shouldAnnounceExhaustion(handle)) { + args.showToast("Couldn't fit the terminal to this screen", 4000) + } + return + } + const viewportWasMeasured = args.viewportMeasuredRef.current + void (async () => { + // Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06). + await args.getTerminalRef(handle)?.awaitReady() + if ( + args.subscribeSeqRef.current.get(handle) !== seq || + !budget.isRetryGenerationCurrent(handle, retryGeneration) + ) { + return + } + const dims = await args + .getTerminalRef(handle) + ?.measureFitDimensions(args.terminalFrameHeightRef.current || undefined) + // Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation. + if ( + args.subscribeSeqRef.current.get(handle) !== seq || + !budget.isRetryGenerationCurrent(handle, retryGeneration) + ) { + return + } + if (!args.getTerminalRef(handle) || !dims) { + return + } + args.viewportRef.current = dims + args.viewportMeasuredRef.current = true + if ( + !shouldResubscribeAfterViewportMeasure({ + hostCols, + hostRows, + measured: dims, + viewportWasMeasured + }) + ) { + // Why: the pre-measure mismatch was a stale cached viewport; the server already agrees. + budget.markConverged(handle) + return + } + const resubscribe = (): void => { + if ( + args.subscribeSeqRef.current.get(handle) !== seq || + !budget.isRetryGenerationCurrent(handle, retryGeneration) + ) { + return + } + if (!args.getTerminalRef(handle)) { + return + } + diagnostics.streamResubscribing(handle, seq, dims, budget.attempts(handle), decision.delayMs) + args.unsubscribeTerminal(handle) + args.initializedHandlesRef.current.delete(handle) + args.subscribeToTerminal(handle) + // Why: only a resubscribe that actually armed spends budget; one turned away by its own gates never reached the host. + if (args.terminalUnsubsRef.current.has(handle)) { + budget.chargeAttempt(handle) + } + } + if (decision.delayMs > 0) { + // Why: keep the live stream up through the backoff so input keeps flowing; teardown happens only when the retry fires. + args.scheduleDelayedAction(resubscribe, decision.delayMs) + } else { + resubscribe() + } + })() +} diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 450437f58be..d20524749bb 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -2974,8 +2974,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ serialized: serialized?.data, oscLinks: serialized?.oscLinks, cwd: serialized?.cwd, - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, + // Why: an empty snapshot with no PTY size must still report the dims the fit + // will produce — dimless frames re-armed the mobile fit loop (STA-3337). + cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols, + rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows, displayMode, seq }) @@ -3381,8 +3383,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) const snapshotStats = sendSnapshotFrames(sendFrame, { kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, + // Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has + // no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337). + cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, displayMode, seq: snapshotFrameSeq, cwd: serialized?.cwd, From c9c1683838709b505d1f5f9de3e2c1cb58135642 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:02:42 -0700 Subject: [PATCH 008/101] fix(ci): seed the adhoc release repo so releases can be tagged (#12375) gh repo create leaves the repo empty, and publishing a release creates a git tag that needs a commit to point at. The adhoc build got all the way through notarization and upload before failing on "Repository is empty". Co-authored-by: Orca --- config/scripts/setup-adhoc-release-repo.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/config/scripts/setup-adhoc-release-repo.sh b/config/scripts/setup-adhoc-release-repo.sh index d239d930717..3e232c5fce8 100755 --- a/config/scripts/setup-adhoc-release-repo.sh +++ b/config/scripts/setup-adhoc-release-repo.sh @@ -39,14 +39,23 @@ else # Why the features are off: this repo holds releases and nothing else. Leaving # issues open invites bug reports against a branch build in a repo nobody # watches, where they are simply lost. + # + # Why --add-readme in a repo with no source: publishing a release creates a tag, + # and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in. gh repo create "$ADHOC_REPO" \ --public \ --description "Adhoc macOS dev builds of Orca, cut from unlanded branches. Not a source repo." \ + --add-readme \ --disable-issues \ --disable-wiki || fail "Could not create $ADHOC_REPO." fi +# Also checked outside the create branch: a repo made before --add-readme is here. +if ! gh api "repos/$ADHOC_REPO/commits" --jq 'length' >/dev/null 2>&1; then + fail "$ADHOC_REPO has no commits — releases cannot be tagged. Add any file to it first." +fi + echo echo "Granting $APP_SLUG access to $ADHOC_REPO..." From 8ab7d8a1103579056f8c9c4c63f0371c19e1eb11 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:02:45 -0700 Subject: [PATCH 009/101] fix(updater): base dev builds on published tags, not main's package.json (#12376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main's version only moves on `release:` commits, and stable patches are cut from release branches that never merge back. On 2026-08-03 main read 1.4.165-rc.0 for twenty hours while 1.4.165, 1.4.166 and 1.4.167 all shipped, so every hourly built in that window was stamped 1.4.165-hourly.* while carrying code newer than 1.4.167 — and sorted below the stable its user was already running. Resolve the base from the main repo's published tags instead, taking the patch above the highest shipped stable. package.json stays a floor for the case where main leads the tags. Co-authored-by: Orca --- .github/workflows/adhoc-mac-build.yml | 23 +++++-- .github/workflows/hourly-mac-build.yml | 15 ++++- config/scripts/adhoc-build-version.mjs | 15 ++++- config/scripts/dev-channel-base-version.mjs | 66 ++++++++++++++++++ .../scripts/dev-channel-base-version.test.mjs | 67 +++++++++++++++++++ config/scripts/hourly-build-version.mjs | 11 ++- 6 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 config/scripts/dev-channel-base-version.mjs create mode 100644 config/scripts/dev-channel-base-version.test.mjs diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 325ab73a847..a41f464f806 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -146,17 +146,26 @@ jobs: env: REF: ${{ inputs.ref || github.ref_name }} LABEL: ${{ inputs.label }} + MAIN_REPO_TOKEN: ${{ github.token }} run: | set -euo pipefail - # Why this check: the version script is read from the branch being built, + # Why this check: the version scripts are read from the branch being built, # not from main, so a branch cut before the adhoc channel landed has no - # copy of it. Say that plainly instead of failing with a module-not-found. - if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then - echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel." - exit 1 - fi + # copy of them. Say that plainly instead of failing with a module-not-found. + for script in adhoc-build-version dev-channel-base-version; do + if [[ ! -f "config/scripts/$script.mjs" ]]; then + echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel." + exit 1 + fi + done echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - ORCA_ADHOC_LABEL="${LABEL:-$REF}" node config/scripts/adhoc-build-version.mjs \ + # Why the main repo's tags: package.json on a branch is as stale as the + # main it forked from, and stable patches never merge back into it. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ + --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ + --json tagName --jq '.[].tagName' || true)" + ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \ + node config/scripts/adhoc-build-version.mjs \ >"$RUNNER_TEMP/adhoc-identity.txt" if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow." diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index ebe544436a6..bc3c5a8afff 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -179,6 +179,7 @@ jobs: shell: bash env: GH_TOKEN: ${{ steps.app_token.outputs.token }} + MAIN_REPO_TOKEN: ${{ github.token }} run: | set -euo pipefail # Why read the highest number off existing titles rather than counting @@ -195,7 +196,19 @@ jobs: --jq '[.[] | (.name // "") | capture(" • (?[0-9]+) • ")? | .n | tonumber] | max // 0')" build_number=$(( last_number + 1 )) echo "Hourly build number $build_number (previous high: $last_number)" - ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \ + # Why the main repo's tags decide the base version rather than + # package.json: main's version only moves on `release:` commits, and + # stable patches are cut from release branches that never merge back, so + # package.json can sit several patches behind what users are running. A + # separate token because GH_TOKEN above is the App's, scoped to the + # hourly repo. Empty on failure — the script then falls back to + # package.json, which is stale but never wrong enough to fail a build. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ + --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ + --json tagName --jq '.[].tagName' || true)" + echo "Highest published tag seen: $(head -1 <<<"$published")" + ORCA_PUBLISHED_VERSIONS="$published" \ + ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \ >"$RUNNER_TEMP/hourly-identity.txt" # Why check rather than trust: the checkout above pins `ref: main`, but a # workflow_dispatch runs this file from whatever branch was dispatched. A diff --git a/config/scripts/adhoc-build-version.mjs b/config/scripts/adhoc-build-version.mjs index db7c95676d7..b62f28d1c4f 100644 --- a/config/scripts/adhoc-build-version.mjs +++ b/config/scripts/adhoc-build-version.mjs @@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs' +import { + readPublishedVersionsFromEnv, + resolveDevChannelBaseVersion +} from './dev-channel-base-version.mjs' /** Long enough to name a feature, short enough that a picker row stays readable. */ export const ADHOC_LABEL_MAX_LENGTH = 32 @@ -83,12 +87,13 @@ export function formatAdhocReleaseName(version, label, commit, date) { ].join(' • ') } -export function getAdhocBuildIdentity(now = new Date(), label = '') { +export function getAdhocBuildIdentity(now = new Date(), label = '', publishedVersions = []) { const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { encoding: 'utf8' }).trim() - const version = createAdhocBuildVersion(packageJson.version, now) + const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions) + const version = createAdhocBuildVersion(base, now) return { commit, version, @@ -98,7 +103,11 @@ export function getAdhocBuildIdentity(now = new Date(), label = '') { } if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { - const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '') + const identity = getAdhocBuildIdentity( + new Date(), + process.env.ORCA_ADHOC_LABEL ?? '', + readPublishedVersionsFromEnv() + ) // Consumed by the workflow via $GITHUB_OUTPUT. process.stdout.write( `version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n` diff --git a/config/scripts/dev-channel-base-version.mjs b/config/scripts/dev-channel-base-version.mjs new file mode 100644 index 00000000000..62a28c6a374 --- /dev/null +++ b/config/scripts/dev-channel-base-version.mjs @@ -0,0 +1,66 @@ +const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/ + +function parseVersionTriple(value) { + const match = SEMVER.exec(String(value ?? '').trim()) + if (!match) { + return null + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null + } +} + +function compareTriples(a, b) { + return a.major - b.major || a.minor - b.minor || a.patch - b.patch +} + +/** + * The base `X.Y.Z` an hourly or adhoc build should carry. + * + * Why not package.json alone: main's version only moves on `release:` commits, and + * stable patches are cut from release branches that never merge back. On + * 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and + * 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while + * carrying code newer than 1.4.167, and sorted *below* the stable their user was + * already running. Published tags are the only honest answer to "what number is + * taken"; package.json is a floor, not a source of truth. + */ +export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) { + const fromPackage = parseVersionTriple(packageVersion) + if (!fromPackage) { + throw new Error(`Package version is not valid semver: ${packageVersion}`) + } + + // Unparseable tags are skipped rather than fatal: the main repo carries old tags + // that predate the current scheme, and one of them must not fail every build. + const published = publishedVersions.map(parseVersionTriple).filter(Boolean) + + let base = fromPackage + if (published.length > 0) { + const highest = published.reduce((best, entry) => + compareTriples(entry, best) > 0 ? entry : best + ) + // A shipped stable owns its number, so the next dev build belongs on the patch + // above it. A bare prerelease does not — rc.1 of 1.4.168 means 1.4.168 is still + // the version being worked toward, which is exactly what main is building. + const shipped = published.some( + (entry) => !entry.prerelease && compareTriples(entry, highest) === 0 + ) + const next = shipped ? { ...highest, patch: highest.patch + 1 } : highest + if (compareTriples(next, base) > 0) { + base = next + } + } + + return `${base.major}.${base.minor}.${base.patch}` +} + +/** Tag list the workflow reads out of the main repo, newline separated. */ +export function readPublishedVersionsFromEnv(value = process.env.ORCA_PUBLISHED_VERSIONS) { + return String(value ?? '') + .split(/\s+/) + .filter(Boolean) +} diff --git a/config/scripts/dev-channel-base-version.test.mjs b/config/scripts/dev-channel-base-version.test.mjs new file mode 100644 index 00000000000..d2631eff0e9 --- /dev/null +++ b/config/scripts/dev-channel-base-version.test.mjs @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + readPublishedVersionsFromEnv, + resolveDevChannelBaseVersion +} from './dev-channel-base-version.mjs' + +describe('dev channel base version', () => { + it('falls back to package.json when no tags are supplied', () => { + expect(resolveDevChannelBaseVersion('1.4.168-rc.1')).toBe('1.4.168') + expect(resolveDevChannelBaseVersion('1.4.168', [])).toBe('1.4.168') + }) + + // The bug this exists for: main sat at 1.4.165-rc.0 for twenty hours while three + // stables shipped, so hourlies claimed a version their users had already passed. + it('climbs past stables that shipped while main stood still', () => { + expect( + resolveDevChannelBaseVersion('1.4.165-rc.0', [ + 'v1.4.165', + 'v1.4.166', + 'v1.4.167', + 'v1.4.165-rc.0' + ]) + ).toBe('1.4.168') + }) + + // Why +1 on a stable but not on a prerelease: 1.4.167 is spent, so the next dev + // build is 1.4.168. But 1.4.168-rc.1 means 1.4.168 is still being built toward, + // which is what main holds — claiming 1.4.169 would jump a release nobody cut. + it('takes the patch above a shipped stable and holds at an open prerelease', () => { + expect(resolveDevChannelBaseVersion('1.4.167', ['v1.4.167'])).toBe('1.4.168') + expect(resolveDevChannelBaseVersion('1.4.168-rc.1', ['v1.4.168-rc.1'])).toBe('1.4.168') + }) + + // Why max and not most-recent: a hotfix on an old line published today would + // otherwise drag every subsequent hourly backwards. + it('reads the highest tag, not the last one listed', () => { + expect(resolveDevChannelBaseVersion('1.4.160', ['v1.4.167', 'v1.3.99', 'v1.4.120'])).toBe( + '1.4.168' + ) + }) + + it('treats package.json as a floor when it leads the tags', () => { + expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0') + }) + + // Why skipped rather than fatal: the main repo carries legacy tags, and one + // unparseable entry must not fail every hourly build. + it('ignores tags it cannot parse', () => { + expect(resolveDevChannelBaseVersion('1.4.160', ['nightly', '', 'v1.4.167', 'latest'])).toBe( + '1.4.168' + ) + }) + + it('rejects a package version that is not semver', () => { + expect(() => resolveDevChannelBaseVersion('not-a-version')).toThrow(/not valid semver/) + }) + + it('carries major and minor rollovers through the bump', () => { + expect(resolveDevChannelBaseVersion('1.4.0', ['v2.0.0'])).toBe('2.0.1') + }) + + it('splits an env tag list on any whitespace', () => { + expect(readPublishedVersionsFromEnv('v1.4.167\nv1.4.166\n')).toEqual(['v1.4.167', 'v1.4.166']) + expect(readPublishedVersionsFromEnv('')).toEqual([]) + expect(readPublishedVersionsFromEnv(undefined)).toEqual([]) + }) +}) diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs index f4545af086b..12257c2b4ff 100644 --- a/config/scripts/hourly-build-version.mjs +++ b/config/scripts/hourly-build-version.mjs @@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs' +import { + readPublishedVersionsFromEnv, + resolveDevChannelBaseVersion +} from './dev-channel-base-version.mjs' /** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically * by semver and every build is uniquely versioned. */ @@ -45,18 +49,19 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) { ].join(' • ') } -export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) { +export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1, publishedVersions = []) { const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { encoding: 'utf8' }).trim() - const version = createHourlyBuildVersion(packageJson.version, now) + const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions) + const version = createHourlyBuildVersion(base, now) return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) } } if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1') - const identity = getHourlyBuildIdentity(new Date(), buildNumber) + const identity = getHourlyBuildIdentity(new Date(), buildNumber, readPublishedVersionsFromEnv()) // Consumed by the workflow via $GITHUB_OUTPUT. process.stdout.write( `version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n` From ce8b778d3138cb905c395da51c625d9c34cf96e1 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 18:11:30 -0700 Subject: [PATCH 010/101] perf(runtime): withhold unchanged mobile snapshots from the graph payload (#12245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(runtime): withhold unchanged mobile snapshots from the graph payload Every graph sync structured-cloned all 222 worktree snapshots to main even when none had changed: 374 KB and ~5 ms per clone, paid twice because Electron clones on serialize and again on deserialize. That transport cost — not the renderer rebuild — is the bulk of a publication. The renderer now sends only the snapshots main has not acknowledged and names the rest in unchangedMobileSessionWorktrees. Detection is object identity, not a deep compare: an unchanged worktree already returns its cached snapshot object. Main seeds nextWorktrees from that list so its prune keeps withheld worktrees live instead of removing them. The call itself is unconditional. syncWindowGraph is not a one-way publish — its return value is the only channel carrying agentOrchestrationByPaneKey to the renderer, and the handler adopts pre-allocated handles, merges detached leaves, refreshes writable flags, and drains graph-sync callbacks on every sync. Skipping it would starve all of that. Two failure modes are closed explicitly. The memo advances only after main acknowledges, so a publication that throws is resent in full rather than silently withheld forever. And a worktree main dropped on its own — worktree metadata removal — comes back in mobileSessionResyncWorktrees, which also clears the accepted-revision record so the republish is not rejected as a no-op. Unchanged republish at 222 worktrees / 787 tabs: 374 KB to 3.4 KB, 5.08 ms to 0.02 ms per clone. One changed worktree: 5.3 KB. * fix(runtime): resync stale withheld mobile snapshots * fix(runtime): align accepted mobile snapshot membership --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../graph-sync-payload-partition.test.ts | 264 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 81 ++++-- ...nc-runtime-graph-payload-partition.test.ts | 209 ++++++++++++++ .../src/runtime/sync-runtime-graph.ts | 53 +++- src/shared/runtime-types.ts | 7 + 5 files changed, 597 insertions(+), 17 deletions(-) create mode 100644 src/main/runtime/graph-sync-payload-partition.test.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts diff --git a/src/main/runtime/graph-sync-payload-partition.test.ts b/src/main/runtime/graph-sync-payload-partition.test.ts new file mode 100644 index 00000000000..6533a53f9fb --- /dev/null +++ b/src/main/runtime/graph-sync-payload-partition.test.ts @@ -0,0 +1,264 @@ +/** + * The renderer withholds unchanged mobile-session snapshots from the graph + * payload. Main must treat those worktrees as live rather than pruning them as + * removed, and must ask for a republish of any it no longer holds. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot, + RuntimeSyncWindowGraphResult +} from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/types' +import { OrcaRuntimeService } from './orca-runtime' + +const WT_A = 'repo-1::/tmp/worktree-a' +const WT_B = 'repo-1::/tmp/worktree-b' + +const storeBase = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [storeBase.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +function makeSession(overrides: Partial = {}): WorkspaceSessionState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: WT_A, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...overrides + } +} + +function makeTerminalTab(id: string, ptyId: string) { + return { + id, + ptyId, + worktreeId: WT_A, + title: `Terminal ${id}`, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +type RuntimeInternals = { + mobileSessionTabsByWorktree: Map +} + +function createRuntime() { + let session = makeSession() + const runtime = new OrcaRuntimeService({ + ...storeBase, + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + } + }) + const events: RuntimeMobileSessionTabsResult[] = [] + runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + const setSession = (next: WorkspaceSessionState): void => { + session = next + } + const sync = ( + mobileSessionTabs: RuntimeMobileSessionTabsSnapshot[], + unchangedMobileSessionWorktrees?: string[] + ): RuntimeSyncWindowGraphResult => + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs, + ...(unchangedMobileSessionWorktrees ? { unchangedMobileSessionWorktrees } : {}) + }) + return { runtime, events, sync, setSession, internals: runtime as unknown as RuntimeInternals } +} + +function makeSnapshot(worktree: string, version: number): RuntimeMobileSessionTabsSnapshot { + return { + worktree, + publicationEpoch: 'renderer:test-epoch', + snapshotVersion: version, + activeGroupId: 'group-1', + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: `Terminal ${worktree}`, + isActive: true + } + ] + } +} + +describe('graph-sync mobile payload partition', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps a withheld worktree alive instead of pruning it as removed', () => { + const { events, sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + const storedB = internals.mobileSessionTabsByWorktree.get(WT_B) + expect(storedB).toBeDefined() + events.length = 0 + + // A publishes a change; B is withheld as unchanged rather than omitted. + sync([makeSnapshot(WT_A, 3)], [WT_B]) + vi.advanceTimersByTime(300) + + expect(internals.mobileSessionTabsByWorktree.get(WT_B)).toBe(storedB) + expect(events.filter((event) => 'removed' in event && event.removed === true)).toEqual([]) + expect(events.map((event) => event.worktree)).toEqual([WT_A]) + }) + + it('still prunes a worktree the renderer omits from both lists', () => { + const { events, sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + events.length = 0 + + sync([makeSnapshot(WT_A, 3)], []) + vi.advanceTimersByTime(300) + + expect(internals.mobileSessionTabsByWorktree.has(WT_B)).toBe(false) + const removed = events.filter((event) => 'removed' in event && event.removed === true) + expect(removed.map((event) => event.worktree)).toEqual([WT_B]) + }) + + it('asks for a republish of a withheld worktree it dropped, and accepts the resend', () => { + const { sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + + // Main drops B on its own (worktree metadata removal) while the renderer + // still believes B is delivered. + internals.mobileSessionTabsByWorktree.delete(WT_B) + + const result = sync([], [WT_A, WT_B]) + vi.advanceTimersByTime(300) + expect(result.mobileSessionResyncWorktrees).toEqual([WT_B]) + expect(internals.mobileSessionTabsByWorktree.has(WT_A)).toBe(true) + + // The renderer republishes B at the SAME (epoch, version) it last sent. The + // accept gate must no longer treat that as an already-accepted no-op. + const resend = sync([makeSnapshot(WT_B, 2)], [WT_A]) + vi.advanceTimersByTime(300) + expect(resend.mobileSessionResyncWorktrees).toBeUndefined() + expect(internals.mobileSessionTabsByWorktree.get(WT_B)?.tabs).toEqual([ + expect.objectContaining({ parentTabId: 'tab-1' }) + ]) + }) + + it('asks for a republish when headless hydration masks a dropped renderer snapshot', () => { + const { sync, setSession, internals } = createRuntime() + setSession( + makeSession({ + tabsByWorktree: { [WT_A]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + sync([makeSnapshot(WT_A, 1)]) + internals.mobileSessionTabsByWorktree.delete(WT_A) + + const result = sync([], [WT_A]) + expect(result.mobileSessionResyncWorktrees).toEqual([WT_A]) + expect(internals.mobileSessionTabsByWorktree.get(WT_A)?.publicationEpoch).toMatch( + /^headless-hydrated:/ + ) + + const resend = sync([makeSnapshot(WT_A, 1)], []) + expect(resend.mobileSessionResyncWorktrees).toBeUndefined() + expect( + internals.mobileSessionTabsByWorktree + .get(WT_A) + ?.tabs.map((tab) => (tab.type === 'terminal' ? tab.parentTabId : tab.id)) + ).toEqual(['tab-1', 'serve-tab']) + }) + + it('requests the accepted revision again to prune a stale preserved tab', () => { + const { sync, setSession, internals } = createRuntime() + setSession( + makeSession({ + tabsByWorktree: { [WT_A]: [makeTerminalTab('serve-tab', 'serve-pty-1')] } + }) + ) + sync([makeSnapshot(WT_A, 1)]) + expect( + internals.mobileSessionTabsByWorktree + .get(WT_A) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(true) + + setSession(makeSession()) + const result = sync([], [WT_A]) + expect(result.mobileSessionResyncWorktrees).toEqual([WT_A]) + + sync([makeSnapshot(WT_A, 1)], []) + expect( + internals.mobileSessionTabsByWorktree + .get(WT_A) + ?.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'serve-tab') + ).toBe(false) + }) + + it('does not request a renderer tab rejected by the retirement fence', () => { + const { sync, setSession, internals } = createRuntime() + setSession( + makeSession({ + terminalTopologyRevisionByRepoId: { 'repo-1': 1 } + }) + ) + + sync([makeSnapshot(WT_A, 1)]) + expect(internals.mobileSessionTabsByWorktree.get(WT_A)?.tabs).toEqual([]) + + expect(sync([], [WT_A]).mobileSessionResyncWorktrees).toBeUndefined() + expect(sync([], [WT_A]).mobileSessionResyncWorktrees).toBeUndefined() + }) + + it('leaves the legacy full-payload contract untouched when no list is sent', () => { + const { events, sync, internals } = createRuntime() + sync([makeSnapshot(WT_A, 1), makeSnapshot(WT_B, 2)]) + vi.advanceTimersByTime(300) + events.length = 0 + + // No unchangedMobileSessionWorktrees field at all: omission still prunes. + sync([makeSnapshot(WT_A, 3)]) + vi.advanceTimersByTime(300) + + expect(internals.mobileSessionTabsByWorktree.has(WT_B)).toBe(false) + expect( + events.filter((event) => 'removed' in event && event.removed === true).map((e) => e.worktree) + ).toEqual([WT_B]) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9cae9972a74..95d3cb99780 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2724,7 +2724,12 @@ export class OrcaRuntimeService { // resend (or stale) and is skipped without touching the stored entry. private acceptedRendererMobileSnapshotByWorktree = new Map< string, - { publicationEpoch: string; rendererVersion: number } + { + publicationEpoch: string + rendererVersion: number + rendererTabCount: number + rendererTabIdentityKeys: ReadonlySet + } >() private clientSessionTabSelections = new ClientSessionTabSelectionStore() // Why: idempotency map for mobile terminal creation — a retried create with the @@ -5381,7 +5386,12 @@ export class OrcaRuntimeService { const previousLeaves = this.leaves this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) const lifecycleLeaves = this.reconcileMobileSessionRetirementFences(graph.leaves) - const changedMobileWorktrees = this.syncMobileSessionTabs(graph.mobileSessionTabs) + const mobileSessionResyncWorktrees = new Set() + const changedMobileWorktrees = this.syncMobileSessionTabs( + graph.mobileSessionTabs, + graph.unchangedMobileSessionWorktrees, + mobileSessionResyncWorktrees + ) const nextLeaves = new Map() const graphSyncedAt = this.nextTitleObservationSequence() @@ -5557,7 +5567,10 @@ export class OrcaRuntimeService { return { ...this.getStatus(), ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}), - ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}) + ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}), + ...(mobileSessionResyncWorktrees.size > 0 + ? { mobileSessionResyncWorktrees: [...mobileSessionResyncWorktrees] } + : {}) } } @@ -23207,6 +23220,7 @@ export class OrcaRuntimeService { store.removeWorktreeMeta(worktreeId) } this.mobileSessionTabsByWorktree.delete(worktreeId) + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) this.closeHeadlessBrowserPagesForWorktree(worktreeId) @@ -28849,7 +28863,9 @@ export class OrcaRuntimeService { // Returns the worktrees whose stored snapshot object changed during this // sync, so the caller can fan out only actually-changed worktrees. private syncMobileSessionTabs( - snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined + snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined, + unchangedWorktreeIds?: string[], + resyncWorktreeIds: Set = new Set() ): Set { const changedWorktreeIds = new Set() if (snapshots === undefined) { @@ -28884,6 +28900,38 @@ export class OrcaRuntimeService { }) } const nextWorktrees = new Set() + const incomingWorktreeIds = new Set(snapshots.map((snapshot) => snapshot.worktree)) + // Why: the renderer withholds unchanged snapshots to keep the graph payload + // small, so these worktrees are still live and must not fall into the prune + // below. Ask for a republish when main no longer holds that accepted renderer + // publication or a formerly-preserved runtime tab has gone stale. + for (const worktreeId of unchangedWorktreeIds ?? []) { + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(worktreeId) + if (existing) { + nextWorktrees.add(worktreeId) + } + if ( + existing && + accepted && + (existing.publicationEpoch === accepted.publicationEpoch || + existing.publicationEpoch.startsWith(`${accepted.publicationEpoch}:headless-merge:`)) && + existing.tabs.length >= accepted.rendererTabCount && + (existing.tabs.length === accepted.rendererTabCount || + !this.storedMobileSnapshotHasStalePreservedTab( + existing, + accepted.rendererTabIdentityKeys + )) + ) { + continue + } + if (!incomingWorktreeIds.has(worktreeId)) { + resyncWorktreeIds.add(worktreeId) + } + // Why: the accept gate compares against the renderer's last accepted pair, + // which outlives the dropped snapshot and would reject the republish. + this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) + } for (const snapshot of snapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) @@ -28908,7 +28956,7 @@ export class OrcaRuntimeService { !( existing && snapshot.snapshotVersion === accepted.rendererVersion && - this.storedMobileSnapshotHasStalePreservedTab(existing, snapshot) + this.storedMobileSnapshotHasStalePreservedTab(existing, accepted.rendererTabIdentityKeys) ) ) { continue @@ -28932,7 +28980,11 @@ export class OrcaRuntimeService { ) this.acceptedRendererMobileSnapshotByWorktree.set(snapshot.worktree, { publicationEpoch: snapshot.publicationEpoch, - rendererVersion: snapshot.snapshotVersion + rendererVersion: snapshot.snapshotVersion, + rendererTabCount: fencedSnapshot.tabs.length, + rendererTabIdentityKeys: new Set( + fencedSnapshot.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) + ) }) } for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) { @@ -29054,22 +29106,19 @@ export class OrcaRuntimeService { } // Why: the accepted-revision no-op gate must not fossilize preserved runtime - // tabs. A stored merged snapshot's tabs that are absent from the incoming - // renderer publication exist only via preservation; if any such tab no longer + // tabs. A stored merged snapshot's tabs absent from the accepted renderer + // publication exist only via preservation; if any such tab no longer // passes the preservation predicate (binding removed from the live PTY table - // and persisted session, or browser page closed), the stored snapshot is - // stale even though the renderer revision is unchanged. + // and persisted session, or browser page closed), the stored snapshot is stale. private storedMobileSnapshotHasStalePreservedTab( existing: RuntimeMobileSessionTabsSnapshot, - incoming: RuntimeMobileSessionTabsSnapshot + rendererTabIdentityKeys: ReadonlySet ): boolean { - const incomingIds = new Set( - incoming.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) - ) return existing.tabs.some( (tab) => - !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id)) && - !this.shouldPreserveHeadlessMobileSessionTab(existing, tab) + !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => + rendererTabIdentityKeys.has(id) + ) && !this.shouldPreserveHeadlessMobileSessionTab(existing, tab) ) } diff --git a/src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts b/src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts new file mode 100644 index 00000000000..aac5de9b2b7 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + scheduleRuntimeGraphSync, + setRuntimeGraphStoreStateGetter, + setRuntimeGraphSyncEnabled +} from './sync-runtime-graph' +import type { AppState } from '../store/types' +import type { RuntimeSyncWindowGraph } from '../../../shared/runtime-types' + +function leafIdFor(index: number): string { + return `${String(index).padStart(8, '0')}-1111-4111-8111-111111111111` +} + +/** Distinct ids per test: the publication memo is module state shared in this file. */ +function makeState(prefix: string, worktreeCount: number, titles: string[] = []): AppState { + const tabsByWorktree: Record = {} + const terminalLayoutsByTabId: Record = {} + for (let i = 0; i < worktreeCount; i++) { + const tabId = `${prefix}-term-${i}` + tabsByWorktree[`repo::/${prefix}-wt-${i}`] = [ + { id: tabId, title: titles[i] ?? `Agent ${i}`, customTitle: null, ptyId: null } + ] + terminalLayoutsByTabId[tabId] = { + root: { type: 'leaf', leafId: leafIdFor(i) }, + activeLeafId: leafIdFor(i), + expandedLeafId: null + } + } + return { + tabsByWorktree, + terminalLayoutsByTabId, + runtimePaneTitlesByTabId: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + layoutByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + agentStatusByPaneKey: {}, + browserTabsByWorktree: {} + } as unknown as AppState +} + +function graphOf(call: unknown[] | undefined): RuntimeSyncWindowGraph { + return call?.[0] as RuntimeSyncWindowGraph +} + +function publishedWorktrees(call: unknown[] | undefined): string[] { + return (graphOf(call).mobileSessionTabs ?? []).map((snapshot) => snapshot.worktree).sort() +} + +function withheldWorktrees(call: unknown[] | undefined): string[] { + return [...(graphOf(call).unchangedMobileSessionWorktrees ?? [])].sort() +} + +async function flushSync(): Promise { + await vi.advanceTimersByTimeAsync(20) + await Promise.resolve() + await Promise.resolve() +} + +afterEach(() => { + setRuntimeGraphSyncEnabled(false) + setRuntimeGraphStoreStateGetter(null) + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function startSync( + state: AppState, + syncWindowGraph: ReturnType +): { setState: (next: AppState) => void } { + let current = state + vi.useFakeTimers() + vi.stubGlobal('window', { api: { runtime: { syncWindowGraph } } }) + vi.stubGlobal('HTMLElement', class HTMLElement {}) + setRuntimeGraphStoreStateGetter(() => current) + setRuntimeGraphSyncEnabled(true) + return { + setState: (next: AppState) => { + current = next + } + } +} + +describe('mobile session graph payload partition', () => { + it('withholds every unchanged worktree from the republished payload', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + startSync(makeState('a', 3), syncWindowGraph) + + await flushSync() + expect(publishedWorktrees(syncWindowGraph.mock.calls[0])).toEqual([ + 'repo::/a-wt-0', + 'repo::/a-wt-1', + 'repo::/a-wt-2' + ]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[0])).toEqual([]) + + scheduleRuntimeGraphSync() + await flushSync() + + expect(syncWindowGraph).toHaveBeenCalledTimes(2) + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual([]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/a-wt-0', + 'repo::/a-wt-1', + 'repo::/a-wt-2' + ]) + }) + + it('publishes only the worktree whose snapshot changed', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + const { setState } = startSync(makeState('b', 3), syncWindowGraph) + await flushSync() + + setState(makeState('b', 3, ['Agent 0', 'Agent 1 (done)', 'Agent 2'])) + scheduleRuntimeGraphSync() + await flushSync() + + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual(['repo::/b-wt-1']) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/b-wt-0', + 'repo::/b-wt-2' + ]) + }) + + it('republishes a changed worktree whose publication threw before it was acknowledged', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + const { setState } = startSync(makeState('c', 3), syncWindowGraph) + await flushSync() + + // The publication carrying c-wt-1's new snapshot fails in flight. + setState(makeState('c', 3, ['Agent 0', 'Agent 1 (done)', 'Agent 2'])) + syncWindowGraph.mockRejectedValueOnce(new Error('ipc closed')) + scheduleRuntimeGraphSync() + await flushSync() + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual(['repo::/c-wt-1']) + + // Nothing changed since, but main never acknowledged it: resend, don't withhold. + scheduleRuntimeGraphSync() + await flushSync() + + expect(syncWindowGraph).toHaveBeenCalledTimes(3) + expect(publishedWorktrees(syncWindowGraph.mock.calls[2])).toEqual(['repo::/c-wt-1']) + expect(withheldWorktrees(syncWindowGraph.mock.calls[2])).toEqual([ + 'repo::/c-wt-0', + 'repo::/c-wt-2' + ]) + }) + + it('resends everything when the first publication throws', async () => { + const syncWindowGraph = vi.fn().mockRejectedValueOnce(new Error('ipc closed')) + syncWindowGraph.mockResolvedValue({}) + startSync(makeState('d', 3), syncWindowGraph) + await flushSync() + expect(publishedWorktrees(syncWindowGraph.mock.calls[0])).toHaveLength(3) + + scheduleRuntimeGraphSync() + await flushSync() + + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/d-wt-0', + 'repo::/d-wt-1', + 'repo::/d-wt-2' + ]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([]) + }) + + it('republishes worktrees main reports it dropped after acknowledging them', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + startSync(makeState('e', 3), syncWindowGraph) + await flushSync() + + syncWindowGraph.mockResolvedValueOnce({ mobileSessionResyncWorktrees: ['repo::/e-wt-2'] }) + scheduleRuntimeGraphSync() + await flushSync() + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toHaveLength(3) + + // The resync reply schedules its own republish; no store change is needed. + await flushSync() + + expect(syncWindowGraph).toHaveBeenCalledTimes(3) + expect(publishedWorktrees(syncWindowGraph.mock.calls[2])).toEqual(['repo::/e-wt-2']) + expect(withheldWorktrees(syncWindowGraph.mock.calls[2])).toEqual([ + 'repo::/e-wt-0', + 'repo::/e-wt-1' + ]) + }) + + it('drops a removed worktree from both the payload and the withheld list', async () => { + const syncWindowGraph = vi.fn().mockResolvedValue({}) + const { setState } = startSync(makeState('f', 3), syncWindowGraph) + await flushSync() + + setState(makeState('f', 2)) + scheduleRuntimeGraphSync() + await flushSync() + + expect(publishedWorktrees(syncWindowGraph.mock.calls[1])).toEqual([]) + expect(withheldWorktrees(syncWindowGraph.mock.calls[1])).toEqual([ + 'repo::/f-wt-0', + 'repo::/f-wt-1' + ]) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 63f578c7dfb..0f8e28b7fe6 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -217,6 +217,9 @@ const EMPTY_WORKTREE_UNIFIED_TABS: AppState['unifiedTabsByWorktree'][string] = [ const EMPTY_WORKTREE_TAB_GROUPS: AppState['groupsByWorktree'][string] = [] const EMPTY_WORKTREE_OPEN_FILE_IDS: readonly string[] = [] const mobileSessionPublicationEpoch = `renderer:${createBrowserUuid()}` +// Why: the snapshot object main last acknowledged per worktree; anything still +// identical is withheld from the graph payload instead of re-cloned across IPC. +const publishedMobileSessionSnapshotByWorktree = new Map() export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null): void { getStoreState = getter @@ -698,10 +701,13 @@ async function syncRuntimeGraph(): Promise { .map((tab) => [tab.id, tab]) ) const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true + const mobileSessionTabs = buildMobileSessionTabSnapshots(state, systemPrefersDark) + const publication = partitionMobileSessionPublication(mobileSessionTabs) const graph: RuntimeSyncWindowGraph = { tabs: [], leaves: [], - mobileSessionTabs: buildMobileSessionTabSnapshots(state, systemPrefersDark) + mobileSessionTabs: publication.changed, + unchangedMobileSessionWorktrees: publication.unchangedWorktrees } for (const [tabId, registeredTab] of registeredTabs) { @@ -811,6 +817,9 @@ async function syncRuntimeGraph(): Promise { try { const result = await window.api.runtime.syncWindowGraph(graph) + // Why: only an acknowledged publication may be treated as delivered. A throw + // leaves the memo behind, so the retry resends every worktree in full. + commitMobileSessionPublication(mobileSessionTabs, result?.mobileSessionResyncWorktrees) const currentState = getStoreState() currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) { @@ -821,11 +830,53 @@ async function syncRuntimeGraph(): Promise { }) } } + if (result?.mobileSessionResyncWorktrees?.length) { + scheduleRuntimeGraphSync() + } } catch (error) { console.error('[runtime] Failed to sync renderer graph:', error) } } +function partitionMobileSessionPublication(snapshots: RuntimeMobileSessionTabsSnapshot[]): { + changed: RuntimeMobileSessionTabsSnapshot[] + unchangedWorktrees: string[] +} { + const changed: RuntimeMobileSessionTabsSnapshot[] = [] + const unchangedWorktrees: string[] = [] + for (const snapshot of snapshots) { + // Why: buildMobileSessionTabSnapshots returns the cached object for a worktree + // it did not rebuild, so identity — not a deep compare — settles this. + if (publishedMobileSessionSnapshotByWorktree.get(snapshot.worktree) === snapshot) { + unchangedWorktrees.push(snapshot.worktree) + } else { + changed.push(snapshot) + } + } + return { changed, unchangedWorktrees } +} + +function commitMobileSessionPublication( + snapshots: RuntimeMobileSessionTabsSnapshot[], + resyncWorktrees: string[] | undefined +): void { + const published = new Set() + for (const snapshot of snapshots) { + published.add(snapshot.worktree) + publishedMobileSessionSnapshotByWorktree.set(snapshot.worktree, snapshot) + } + for (const worktreeId of publishedMobileSessionSnapshotByWorktree.keys()) { + if (!published.has(worktreeId)) { + publishedMobileSessionSnapshotByWorktree.delete(worktreeId) + } + } + // Why: main dropped these after acknowledging them, so forget the delivery and + // let the scheduled resync republish them in full. + for (const worktreeId of resyncWorktrees ?? []) { + publishedMobileSessionSnapshotByWorktree.delete(worktreeId) + } +} + function narrowRecordByKeys( source: Record | undefined, keys: readonly string[] diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 125d0d588ad..d4d0efc1ad8 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -143,7 +143,11 @@ export type RuntimeSyncedLeaf = { export type RuntimeSyncWindowGraph = { tabs: RuntimeSyncedTab[] leaves: RuntimeSyncedLeaf[] + /** Only worktrees whose snapshot changed since the last acknowledged publication. */ mobileSessionTabs?: RuntimeMobileSessionTabsSnapshot[] + /** Worktrees the renderer is still publishing unchanged; main must keep their + * stored snapshots alive instead of pruning them as removed. */ + unchangedMobileSessionWorktrees?: string[] } export type RuntimeNativeChatLaunchDraftResolution = { @@ -157,6 +161,9 @@ export type RuntimeSyncWindowGraphResult = RuntimeStatus & { * parent metadata needed by title-derived agent rows without name guessing. */ agentOrchestrationByPaneKey?: Record nativeChatLaunchDraftResolutions?: RuntimeNativeChatLaunchDraftResolution[] + /** Worktrees the renderer withheld as unchanged that main holds no snapshot + * for — it dropped them independently, so the renderer must republish them. */ + mobileSessionResyncWorktrees?: string[] } export type RuntimeMobileSessionTerminalTab = { From c4ae923baf9ed8e15d257c17ee7a431cac6c86d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:52:44 -0700 Subject: [PATCH 011/101] fix(ci): vet the adhoc build ref before running it with release secrets (#12161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adhoc workflow checked out any requested ref and ran its scripts and electron-builder config with MAC_CERTS, the notary password, and the adhoc publisher token in reach — including refs/pull/* fork code a maintainer could dispatch in one innocuous-looking click. Vet the ref before checkout: PR refs are refused, branches/tags resolve in a bare tree:0 scratch fetch, raw SHAs must be reachable from a repo branch or tag (a partial clone lazily serves PR-only commits by SHA, so name resolution alone is not a trust test), and checkout pins the vetted SHA so a race push cannot swap the commit. Also reference an adhoc-mac-build environment so the secrets can later be fenced off from stale workflow copies via repo settings. --- .github/workflows/adhoc-mac-build.yml | 70 ++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index a41f464f806..03d2c76ccc0 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -33,6 +33,13 @@ name: Adhoc macOS Dev Build # Contents: Read and write. The secret names below are historical — one App, one # private key, both dev-channel repos — and rotating it stays a single operation. # Provision with `bash config/scripts/setup-hourly-release-token.sh`. +# +# The requested ref is vetted before checkout: it must be a branch or tag of this +# repo, or a commit reachable from one. PR refs are refused outright — this +# workflow runs the checked-out code next to MAC_CERTS and the notary password, +# so "just build that community PR" must not become a way to hand fork code the +# release identity. A branch here always belongs to someone with write access, +# which is the same trust the dispatch button itself already requires. on: workflow_dispatch: @@ -45,7 +52,7 @@ on: # than the one the workflow file itself is read from — normally leave the # picker on main and name your branch here, so a stale copy of this # workflow on an old branch is not what runs. - description: 'Branch, tag, or SHA to build (default: the branch selected above)' + description: 'Branch, tag, or SHA to build — must live in stablyai/orca; PR refs are refused (default: the branch selected above)' required: false default: '' type: string @@ -77,6 +84,14 @@ env: jobs: build-adhoc-mac: if: github.repository == 'stablyai/orca' + # Why an environment: it gives the signing/notary/App secrets somewhere to + # live that a stale copy of this workflow on an old branch cannot reach. + # Referencing it is a no-op until repo settings give it teeth; the intended + # follow-up is to move MAC_CERTS, MAC_CERTS_PASSWORD, APPLE_ID, + # APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, HOURLY_RELEASE_APP_ID, and + # HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch + # policy to main so only main's copy of this file can read them. + environment: adhoc-mac-build runs-on: blacksmith-6vcpu-macos-15 # Why 150: it must exceed the worst case the retry budgets below can produce # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or @@ -85,6 +100,57 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=4096 steps: + # Why vet before checkout: everything after this step runs the checked-out + # code with release signing credentials in reach. Branches and tags of this + # repo are the intended audience; refs/pull/* would smuggle in fork code, + # and a raw SHA is only accepted when some branch or tag of this repo can + # actually reach it. Resolving to a pinned SHA here also means the commit + # that was vetted is the commit that gets checked out — a push to the + # branch between the two steps cannot swap it. + - name: Vet the requested ref + id: vetted + shell: bash + env: + REQUESTED_REF: ${{ inputs.ref || github.ref_name }} + REPO_URL: https://github.com/${{ github.repository }} + run: | + set -euo pipefail + case "$REQUESTED_REF" in + refs/pull/*|pull/*) + echo "::error::Refusing to build PR ref '$REQUESTED_REF': this workflow signs with release credentials, so it only builds branches, tags, or commits of stablyai/orca. Push the code to a branch of this repo instead." + exit 1 + ;; + esac + # Bare: a work-tree repo refuses to fetch over its own checked-out + # branch. tree:0 keeps the fetch to the commit graph — no trees, no + # blobs — so this stays cheap next to the build it fronts. + scratch="$RUNNER_TEMP/vet-requested-ref" + git init -q --bare "$scratch" + git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' + # Branch first to keep actions/checkout's old tie-break: bare + # rev-parse would prefer the tag when a branch shares its name. + sha="" + for cand in "refs/heads/$REQUESTED_REF" "refs/tags/$REQUESTED_REF" "$REQUESTED_REF"; do + if sha="$(git -C "$scratch" rev-parse --verify --quiet "$cand^{commit}")"; then + break + fi + sha="" + done + if [[ -z "$sha" ]]; then + echo "::error::'$REQUESTED_REF' does not resolve to a branch, tag, or commit of stablyai/orca." + exit 1 + fi + # The object resolving locally is not proof a branch or tag reaches + # it: a partial clone can lazily fetch a bare SHA on demand, and + # GitHub serves PR-only commits by SHA. Reachability is the actual + # trust test. + if [[ -z "$(git -C "$scratch" for-each-ref --contains "$sha" refs/heads refs/tags | head -1)" ]]; then + echo "::error::Commit $REQUESTED_REF is not reachable from any branch or tag of stablyai/orca; refusing to build it with release credentials." + exit 1 + fi + echo "Vetted $REQUESTED_REF -> $sha" + echo "sha=$sha" >>"$GITHUB_OUTPUT" + - name: Checkout the requested ref uses: actions/checkout@v6 with: @@ -92,7 +158,7 @@ jobs: # build code that has not landed, and the workflow definition itself # always comes from the dispatch ref — naming the branch here instead # applies main's current copy of this file to an arbitrary branch. - ref: ${{ inputs.ref || github.ref_name }} + ref: ${{ steps.vetted.outputs.sha }} fetch-depth: 0 # This job only reads stablyai/orca and never pushes; every write goes # to the adhoc repo through a minted App token passed by env. Not From 73e7df7764c20f3310e0afc09d2f755a3e392258 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:52:48 -0700 Subject: [PATCH 012/101] fix(browser): track guest recency across kill-switch off, release download veto on interrupt (#12232) Two robustness follow-ups from the #12194 safety-review loop (pass 3), staged by the reviewer before its session ended; both fail toward extra retention only: - recency bookkeeping now runs while the retention kill switch is off, so re-enabling evicts in true LRU order instead of worktree-list order - interrupted downloads release the eviction veto instead of pinning the worktree as downloading forever --- src/renderer/src/components/Terminal.tsx | 7 +- .../browser-page-download-activity.test.ts | 67 +++++++++++++++---- .../browser-page-download-activity.ts | 60 ++++++++++++----- 3 files changed, 102 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 816ae982485..deae7c9d1a8 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1176,7 +1176,7 @@ function Terminal(): React.JSX.Element | null { // and terminal panes/watchers/capture contracts are untouched. A revisit // rebuilds guests from store state. useEffect(() => { - if (!renderedActiveWorktreeId || !browserGuestRetentionBudgetEnabled) { + if (!renderedActiveWorktreeId) { return } const recency = browserGuestWorktreeRecencyRef.current @@ -1187,6 +1187,11 @@ function Terminal(): React.JSX.Element | null { recency.splice(index, 1) } } + // Why after the bookkeeping: recency must track activation order even while + // the kill switch is off, or re-enabling would evict by worktree-list order. + if (!browserGuestRetentionBudgetEnabled) { + return + } const state = useAppStore.getState() // Why appended: a mounted worktree missing from recency (background mount) ranks oldest. const recencyIds = new Set(recency) diff --git a/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts b/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts index 5653f0b5f43..d428f20b409 100644 --- a/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts +++ b/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts @@ -1,34 +1,41 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type DownloadRequestedCallback = (event: { downloadId: string; browserPageId: string }) => void +type DownloadProgressCallback = (event: { + downloadId: string + state: 'progressing' | 'interrupted' | null +}) => void type DownloadFinishedCallback = (event: { downloadId: string }) => void describe('browser page download activity', () => { let requestedCallbacks: DownloadRequestedCallback[] + let progressCallbacks: DownloadProgressCallback[] let finishedCallbacks: DownloadFinishedCallback[] - let removedRequested: boolean - let removedFinished: boolean + let removedListenerCount: number beforeEach(() => { vi.resetModules() requestedCallbacks = [] + progressCallbacks = [] finishedCallbacks = [] - removedRequested = false - removedFinished = false + removedListenerCount = 0 + const removeListener = (): void => { + removedListenerCount += 1 + } vi.stubGlobal('window', { api: { browser: { onDownloadRequested: (callback: DownloadRequestedCallback) => { requestedCallbacks.push(callback) - return () => { - removedRequested = true - } + return removeListener + }, + onDownloadProgress: (callback: DownloadProgressCallback) => { + progressCallbacks.push(callback) + return removeListener }, onDownloadFinished: (callback: DownloadFinishedCallback) => { finishedCallbacks.push(callback) - return () => { - removedFinished = true - } + return removeListener } } } @@ -64,19 +71,54 @@ describe('browser page download activity', () => { expect(hasActiveBrowserPageDownload('page-2')).toBe(false) }) - it('ignores duplicate start events and unknown finish events', async () => { + it('deactivates an interrupted download (no finished event ever fires) and reactivates on resume', async () => { + const { hasActiveBrowserPageDownload, installBrowserPageDownloadActivityTracking } = + await import('./browser-page-download-activity') + installBrowserPageDownloadActivityTracking() + + requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) + progressCallbacks[0]({ downloadId: 'dl-1', state: 'interrupted' }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(false) + + progressCallbacks[0]({ downloadId: 'dl-1', state: 'progressing' }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(true) + + // A null state is transport noise, not a transition. + progressCallbacks[0]({ downloadId: 'dl-1', state: null }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(true) + + finishedCallbacks[0]({ downloadId: 'dl-1' }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(false) + }) + + it('ignores duplicate start events, unknown progress and unknown finish events', async () => { const { hasActiveBrowserPageDownload, installBrowserPageDownloadActivityTracking } = await import('./browser-page-download-activity') installBrowserPageDownloadActivityTracking() requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) + progressCallbacks[0]({ downloadId: 'dl-unknown', state: 'interrupted' }) finishedCallbacks[0]({ downloadId: 'dl-unknown' }) expect(hasActiveBrowserPageDownload('page-1')).toBe(true) finishedCallbacks[0]({ downloadId: 'dl-1' }) expect(hasActiveBrowserPageDownload('page-1')).toBe(false) }) + it('finishing an already-interrupted download stays balanced', async () => { + const { hasActiveBrowserPageDownload, installBrowserPageDownloadActivityTracking } = + await import('./browser-page-download-activity') + installBrowserPageDownloadActivityTracking() + + requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) + requestedCallbacks[0]({ downloadId: 'dl-2', browserPageId: 'page-1' }) + progressCallbacks[0]({ downloadId: 'dl-1', state: 'interrupted' }) + finishedCallbacks[0]({ downloadId: 'dl-1' }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(true) + finishedCallbacks[0]({ downloadId: 'dl-2' }) + expect(hasActiveBrowserPageDownload('page-1')).toBe(false) + }) + it('unsubscribes and clears tracked state on cleanup', async () => { const { hasActiveBrowserPageDownload, installBrowserPageDownloadActivityTracking } = await import('./browser-page-download-activity') @@ -85,8 +127,7 @@ describe('browser page download activity', () => { requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) cleanup() - expect(removedRequested).toBe(true) - expect(removedFinished).toBe(true) + expect(removedListenerCount).toBe(3) // A host remount tears down every guest; stale entries must not veto eviction. expect(hasActiveBrowserPageDownload('page-1')).toBe(false) }) diff --git a/src/renderer/src/components/browser-pane/browser-page-download-activity.ts b/src/renderer/src/components/browser-pane/browser-page-download-activity.ts index 454aba9ad1d..0b3e1af2895 100644 --- a/src/renderer/src/components/browser-pane/browser-page-download-activity.ts +++ b/src/renderer/src/components/browser-pane/browser-page-download-activity.ts @@ -3,36 +3,56 @@ // hidden pages are still writing downloads — main treats a guest unregister as // a tab close and cancels their active downloads (browser-manager // unregisterGuest), so eviction has to skip those pages until they finish. -const pageIdByActiveDownloadId = new Map() +type TrackedDownload = { browserPageId: string; active: boolean } + +const trackedDownloadsById = new Map() const activeDownloadCountByPageId = new Map() export function hasActiveBrowserPageDownload(browserPageId: string): boolean { return (activeDownloadCountByPageId.get(browserPageId) ?? 0) > 0 } -function trackDownloadStarted(downloadId: string, browserPageId: string): void { - if (pageIdByActiveDownloadId.has(downloadId)) { +function setDownloadActive(download: TrackedDownload, active: boolean): void { + if (download.active === active) { return } - pageIdByActiveDownloadId.set(downloadId, browserPageId) - activeDownloadCountByPageId.set( - browserPageId, - (activeDownloadCountByPageId.get(browserPageId) ?? 0) + 1 - ) + download.active = active + const count = (activeDownloadCountByPageId.get(download.browserPageId) ?? 0) + (active ? 1 : -1) + if (count <= 0) { + activeDownloadCountByPageId.delete(download.browserPageId) + } else { + activeDownloadCountByPageId.set(download.browserPageId, count) + } +} + +function trackDownloadStarted(downloadId: string, browserPageId: string): void { + if (trackedDownloadsById.has(downloadId)) { + return + } + const download: TrackedDownload = { browserPageId, active: false } + trackedDownloadsById.set(downloadId, download) + setDownloadActive(download, true) +} + +// Why: an interrupted-resumable download never fires Chromium's 'done' (no +// finished event), and Orca has no resume path — without this transition the +// veto would outlive the download for the whole session. A resumed download +// re-actives on its next 'progressing' tick, restoring the veto. +function trackDownloadProgress(downloadId: string, state: 'progressing' | 'interrupted' | null): void { + const download = trackedDownloadsById.get(downloadId) + if (!download || state === null) { + return + } + setDownloadActive(download, state === 'progressing') } function trackDownloadFinished(downloadId: string): void { - const browserPageId = pageIdByActiveDownloadId.get(downloadId) - if (browserPageId === undefined) { + const download = trackedDownloadsById.get(downloadId) + if (!download) { return } - pageIdByActiveDownloadId.delete(downloadId) - const remaining = (activeDownloadCountByPageId.get(browserPageId) ?? 1) - 1 - if (remaining <= 0) { - activeDownloadCountByPageId.delete(browserPageId) - } else { - activeDownloadCountByPageId.set(browserPageId, remaining) - } + setDownloadActive(download, false) + trackedDownloadsById.delete(downloadId) } /** App-lifetime tracking; install once from the surface host (Terminal). The @@ -42,13 +62,17 @@ export function installBrowserPageDownloadActivityTracking(): () => void { const removeRequested = window.api.browser.onDownloadRequested((event) => { trackDownloadStarted(event.downloadId, event.browserPageId) }) + const removeProgress = window.api.browser.onDownloadProgress((event) => { + trackDownloadProgress(event.downloadId, event.state) + }) const removeFinished = window.api.browser.onDownloadFinished((event) => { trackDownloadFinished(event.downloadId) }) return () => { removeRequested() + removeProgress() removeFinished() - pageIdByActiveDownloadId.clear() + trackedDownloadsById.clear() activeDownloadCountByPageId.clear() } } From e39cdb897dddd238deec3d7fd18eb38c0d6c9041 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:14:28 -0700 Subject: [PATCH 013/101] perf(agent-status): coalesce live status bursts into one render pass (STA-3328) (#12377) * perf(agent-status): coalesce live status bursts into one render pass (STA-3328) Each live agent-status event arrives as its own IPC task, so a multi-agent burst paid one full React render pass per event (200-488ms main-thread tasks under heavy orchestration). Same-task store commits batch to a single render pass, so buffer a burst for one 33ms window and apply it in one task. The leading event still applies synchronously (zero added latency for single events), only an applied event arms the window (dropped/pending events stay immediate), and both clear paths purge queued sets so a coalesced event cannot resurrect a removed status. * fix(agent-status): preserve queued completion on teardown --- src/renderer/src/hooks/useIpcEvents.test.ts | 196 ++++++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 91 ++++++++- 2 files changed, 286 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 3d897577e77..e59b6db6b95 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -6160,6 +6160,202 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('applies a burst leading-edge first, then coalesces the rest into one deferred batch', async () => { + // Why: each live status event is its own IPC task, so N events used to pay + // N full render passes (STA-3328 mechanism 2). The leading event must stay + // synchronous (zero added latency); followers within the burst window must + // apply together on one later task, in arrival order. + vi.useFakeTimers() + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + try { + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + const emit = (receivedAt: number, prompt: string): void => { + onSetListenerRef.current!({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt, + agentType: 'claude', + receivedAt, + stateStartedAt: receivedAt + }) + } + + emit(1_700_000_000_000, 'first') + expect(setAgentStatus).toHaveBeenCalledTimes(1) + + emit(1_700_000_000_001, 'second') + emit(1_700_000_000_002, 'third') + expect(setAgentStatus).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(40) + expect(setAgentStatus).toHaveBeenCalledTimes(3) + expect(setAgentStatus.mock.calls[1][1]).toEqual(expect.objectContaining({ prompt: 'second' })) + expect(setAgentStatus.mock.calls[2][1]).toEqual(expect.objectContaining({ prompt: 'third' })) + } finally { + vi.useRealTimers() + } + }) + + it('preserves queued set-clear order for working removal and done retention', async () => { + vi.useFakeTimers() + let storeState: StoreLike + const setAgentStatus = vi.fn( + ( + paneKey: string, + payload: { state: string }, + _title: unknown, + timing: { updatedAt: number } + ) => { + storeState.agentStatusByPaneKey = { + ...(storeState.agentStatusByPaneKey as Record), + [paneKey]: { ...payload, updatedAt: timing.updatedAt } + } + } + ) + const removeAgentStatus = vi.fn((paneKey: string) => { + const next = { ...(storeState.agentStatusByPaneKey as Record) } + delete next[paneKey] + storeState.agentStatusByPaneKey = next + }) + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const onClearListenerRef: { current: ((data: { paneKey: string }) => void) | null } = { + current: null + } + storeState = buildStoreState({ + setAgentStatus, + removeAgentStatus, + workspaceSessionReady: true, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + }, + onClear: (cb) => { + onClearListenerRef.current = cb as (data: { paneKey: string }) => void + return () => {} + } + }) + ) + + try { + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + if ( + typeof onSetListenerRef.current !== 'function' || + typeof onClearListenerRef.current !== 'function' + ) { + throw new Error('Expected agentStatus listeners to be registered') + } + const emit = ( + receivedAt: number, + prompt: string, + state: AgentStatusSetData['state'] = 'working' + ): void => { + onSetListenerRef.current!({ + paneKey: FUTURE_PANE_KEY, + state, + prompt, + agentType: 'claude', + receivedAt, + stateStartedAt: receivedAt + }) + } + + emit(1_700_000_000_000, 'leading') + emit(1_700_000_000_001, 'queued') + expect(setAgentStatus).toHaveBeenCalledTimes(1) + + onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) + expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY) + expect(storeState.agentStatusByPaneKey).toEqual({}) + + vi.advanceTimersByTime(40) + expect(setAgentStatus).toHaveBeenCalledTimes(2) + expect(storeState.agentStatusByPaneKey).toEqual({}) + + emit(1_700_000_000_002, 'task') + emit(1_700_000_000_003, 'task', 'done') + expect(setAgentStatus).toHaveBeenCalledTimes(3) + + onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) + + expect(setAgentStatus).toHaveBeenCalledTimes(4) + expect(setAgentStatus.mock.calls[3][1]).toEqual(expect.objectContaining({ state: 'done' })) + expect(removeAgentStatus).toHaveBeenCalledTimes(1) + expect(storeState.agentStatusByPaneKey).toEqual({ + [FUTURE_PANE_KEY]: expect.objectContaining({ state: 'done' }) + }) + vi.advanceTimersByTime(40) + expect(setAgentStatus).toHaveBeenCalledTimes(4) + } finally { + vi.useRealTimers() + } + }) + it('does not recurse when flushing a pending status re-enters via the store subscriber', async () => { // Repro for crash 9fc89529 (RangeError: Maximum call stack size exceeded): // the store subscriber calls flushPendingAgentStatuses() on every update. diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index f7f66f0ceac..be6a5e4b2d3 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -288,6 +288,11 @@ export { resolveZoomTarget } from './resolve-zoom-target' const PENDING_AGENT_STATUS_RETRY_MS = 100 const PENDING_AGENT_STATUS_TTL_MS = 15_000 const MAX_PENDING_AGENT_STATUS_EVENTS = 100 +// Why: each live status event is its own IPC task, so a multi-agent burst pays +// one full render pass per event; same-task commits batch to ONE pass (React +// flushes external-store updates at the microtask boundary). One frame of +// buffering collapses a burst; the leading event still applies immediately. +const LIVE_AGENT_STATUS_BURST_WINDOW_MS = 33 // Why: mobile driver hydration is async; cap replay so a stuck IPC snapshot can't retain an unbounded startup buffer. const MAX_PENDING_MOBILE_STATE_EVENTS = 300 // Why: a rename's event burst lags the on-disk move; shield both ids from the deletion diff for a grace window. @@ -758,6 +763,9 @@ export function useIpcEvents(): void { let pendingAgentStatusRetryTimer: ReturnType | null = null // Why: setAgentStatus notifies synchronously and re-enters this flush mid-drain; guard re-entrancy (crash 9fc89529). let isFlushingAgentStatuses = false + const liveAgentStatusBurstQueue: AgentStatusIpcPayload[] = [] + let liveAgentStatusBurstTimer: ReturnType | null = null + let lastLiveAgentStatusApplyAt = 0 unsubs.push(attachMobileMarkdownBridge()) @@ -3337,9 +3345,67 @@ export function useIpcEvents(): void { }) } + function flushLiveAgentStatusBurst(): void { + liveAgentStatusBurstTimer = null + lastLiveAgentStatusApplyAt = Date.now() + // Why: splice before applying — applyAgentStatus can synchronously + // re-enter the queue via subscribers, and it must not see this batch. + const batch = liveAgentStatusBurstQueue.splice(0) + let anyApplied = false + for (const data of batch) { + if (applyAgentStatus(data) === 'applied') { + anyApplied = true + } + } + if (!anyApplied) { + lastLiveAgentStatusApplyAt = 0 + } + } + + function drainQueuedLiveAgentStatusesForPane(paneKey: string): void { + const queuedForPane: AgentStatusIpcPayload[] = [] + const remaining: AgentStatusIpcPayload[] = [] + for (const queued of liveAgentStatusBurstQueue) { + if (queued.paneKey === paneKey) { + queuedForPane.push(queued) + } else { + remaining.push(queued) + } + } + liveAgentStatusBurstQueue.length = 0 + liveAgentStatusBurstQueue.push(...remaining) + for (const queued of queuedForPane) { + applyAgentStatus(queued) + } + } + + function enqueueLiveAgentStatus(data: AgentStatusIpcPayload): void { + const now = Date.now() + if ( + liveAgentStatusBurstTimer === null && + now - lastLiveAgentStatusApplyAt >= LIVE_AGENT_STATUS_BURST_WINDOW_MS + ) { + lastLiveAgentStatusApplyAt = now + // Why: only an applied event commits state and costs a render pass — + // a dropped/pending leading edge must not make its successor pay + // burst latency (startup replay and unmounted panes stay immediate). + if (applyAgentStatus(data) !== 'applied') { + lastLiveAgentStatusApplyAt = 0 + } + return + } + liveAgentStatusBurstQueue.push(data) + if (liveAgentStatusBurstTimer === null) { + liveAgentStatusBurstTimer = globalThis.setTimeout( + flushLiveAgentStatusBurst, + LIVE_AGENT_STATUS_BURST_WINDOW_MS + ) + } + } + unsubs.push( window.api.agentStatus.onSet((data) => { - applyAgentStatus(data) + enqueueLiveAgentStatus(data) }) ) const unsubscribeAgentStatusClear = window.api.agentStatus.onClear?.( @@ -3368,12 +3434,30 @@ export function useIpcEvents(): void { pendingAgentStatusEvents.splice(index, 1) } } + for (let index = liveAgentStatusBurstQueue.length - 1; index >= 0; index -= 1) { + const queued = liveAgentStatusBurstQueue[index] + if ( + queued.connectionId === data.connectionId && + queued.receivedAt <= effectiveWatermark + ) { + liveAgentStatusBurstQueue.splice(index, 1) + } + } useAppStore.getState().clearTransientAgentStatuses(data.connectionId, effectiveWatermark) return } if (!('paneKey' in data) || typeof data.paneKey !== 'string') { return } + // Why: preserve set→clear FIFO so a queued completion still survives pane teardown. + if (liveAgentStatusBurstQueue.some((queued) => queued.paneKey === data.paneKey)) { + drainQueuedLiveAgentStatusesForPane(data.paneKey) + } + for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { + if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) { + pendingAgentStatusEvents.splice(index, 1) + } + } const store = useAppStore.getState() if (store.agentStatusByPaneKey[data.paneKey]?.state === 'done') { return @@ -3565,6 +3649,11 @@ export function useIpcEvents(): void { globalThis.clearTimeout(pendingAgentStatusRetryTimer) } pendingAgentStatusEvents.length = 0 + if (liveAgentStatusBurstTimer !== null) { + globalThis.clearTimeout(liveAgentStatusBurstTimer) + liveAgentStatusBurstTimer = null + } + liveAgentStatusBurstQueue.length = 0 mobileStateHydrationDisposed = true pendingMobileStateEvents.length = 0 unsubscribeRuntimeEnvironmentStore() From 9e5bd5fb842802ab649a4b59f60a0a4e2e1da98d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:18:37 -0700 Subject: [PATCH 014/101] fix(worktrees): fence SSH worktree deletion PTY teardown to the owning host (#12388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Destructive worktree removal swept PTYs by worktree id alone. Worktree ids are `repoId::path` and the store keeps one per host, so deleting an SSH worktree could stop a same-id local (or other-connection) workspace's terminals — or fail outright with `selector_ambiguous` when two hosts owned the id. Every destructive teardown now names its owner (resolvedWorktreeId plus the connection/runtime environment), matching the already-hardened forget-local path: - IPC `worktrees:remove` (git + folder workspaces) - runtime `removeManagedWorktree` (CLI/mobile `worktree.rm`, git + folder) - missing-worktree terminal reconciliation, including its no-provider fallback The #11960 allowUnverifiedStop force-delete gate is untouched. --- src/main/ipc/worktrees.test.ts | 95 +++++++++++++++++++ src/main/ipc/worktrees.ts | 29 +++++- ...g-worktree-terminal-reconciliation.test.ts | 18 +++- ...issing-worktree-terminal-reconciliation.ts | 15 ++- src/main/runtime/orca-runtime.test.ts | 85 +++++++++++++++++ src/main/runtime/orca-runtime.ts | 32 ++++++- 6 files changed, 265 insertions(+), 9 deletions(-) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 62be1f63fa1..8d576b965ee 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -8350,6 +8350,7 @@ describe('registerWorktreeHandlers', () => { expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, { runtime: runtimeStub, + resolvedWorktreeId: worktreeId, localProvider: ptyProvider, onPtyStopped: clearProviderPtyStateMock }) @@ -8364,6 +8365,36 @@ describe('registerWorktreeHandlers', () => { }) }) + // Folder projects can be SSH-backed, and folder workspace ids are `repoId::path::workspace:` + // — reusable across hosts — so the sweep must name the owning connection. + it('fences an SSH folder workspace PTY sweep to the owning connection', async () => { + const sshPtyProvider = { id: 'ssh-pty-provider' } as never + const worktreeId = 'repo-folder::/remote/folder::workspace:child-1' + store.getRepo.mockReturnValue({ + id: 'repo-folder', + path: '/remote/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder', + connectionId: 'conn-1' + }) + getSshPtyProviderMock.mockReturnValue(sshPtyProvider) + + await handlers['worktrees:remove'](null, { worktreeId }) + + expect(getSshPtyProviderMock).toHaveBeenCalledWith('conn-1') + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, { + runtime: runtimeStub, + resolvedWorktreeId: worktreeId, + resolvedConnectionId: 'conn-1', + localProvider: sshPtyProvider, + onPtyStopped: clearProviderPtyStateMock, + includeProviderInventory: true, + includeLocalRegistry: false + }) + }) + it('runs the archive hook on remove when skipArchive is not set', async () => { mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue(undefined) @@ -9950,6 +9981,70 @@ describe('registerWorktreeHandlers', () => { expect(callOrder).toEqual(['preflight', 'kill', 'git']) }) + // Regression: `repoId::path` ids repeat across hosts, so an SSH delete used to reach the + // runtime's same-id local (or other-connection) terminals and stop them. + it('fences an SSH worktree delete PTY sweep to the owning connection', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const sshPtyProvider = { id: 'ssh-pty-provider' } as never + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn().mockResolvedValue([ + { path: '/remote/repo', head: 'main', branch: 'main', isBare: false, isMainWorktree: true }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue({}), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + }) + getSshPtyProviderMock.mockReturnValue(sshPtyProvider) + getEffectiveHooksFromConfigMock.mockReturnValue(null) + + await handlers['worktrees:remove'](null, { worktreeId: 'repo-ssh::/remote/feature-wt' }) + + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith('repo-ssh::/remote/feature-wt', { + runtime: runtimeStub, + resolvedWorktreeId: 'repo-ssh::/remote/feature-wt', + resolvedConnectionId: 'conn-1', + localProvider: sshPtyProvider, + onPtyStopped: clearProviderPtyStateMock, + requirePhysicalStop: true, + includeLocalRegistry: false + }) + }) + + // The local counterpart still identifies itself by exact id so a selector that resolves + // two hosts can no longer decide which workspace loses its terminals. + it('pins a local worktree delete PTY sweep to the exact worktree id', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' }) + + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith( + 'repo-1::/workspace/feature-wt', + expect.objectContaining({ resolvedWorktreeId: 'repo-1::/workspace/feature-wt' }) + ) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith( + 'repo-1::/workspace/feature-wt', + expect.not.objectContaining({ resolvedConnectionId: expect.anything() }) + ) + }) + // Why (#11960): the PTY gate previously had no escape hatch at all, so a // workspace with an unprovable PTY was unremovable forever. it('forwards an explicit Force Delete to the PTY gate', async () => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index b9f2a57cec3..dde33ca7628 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -169,6 +169,11 @@ async function stopPtysForDestructiveWorktreeRemoval( } const teardownResult = await killAllProcessesForWorktree(worktreeId, { runtime, + // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id + // workspace's terminals on another connection — and the selector lookup this replaces + // throws `selector_ambiguous` the moment two hosts own the id. + resolvedWorktreeId: worktreeId, + ...(connectionId ? { resolvedConnectionId: connectionId } : {}), localProvider: provider, onPtyStopped: clearProviderPtyState, requirePhysicalStop: true, @@ -2290,10 +2295,30 @@ export function registerWorktreeHandlers( } // Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata. await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => { + // Folder projects can be SSH-backed, so fence the sweep to the owning host exactly + // like the git paths — the local inventory must never reach a remote workspace's id. + const ownerHost = parseExecutionHostId( + resolveWorktreeRemovalOwnerHostId(store, args.worktreeId, repo, args.hostId) + ) + const sshPtyProvider = + ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined + const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' await killAllProcessesForWorktree(args.worktreeId, { runtime, - localProvider: getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState + resolvedWorktreeId: args.worktreeId, + ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), + ...(ownerHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } + : {}), + localProvider: sshPtyProvider ?? getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState, + ...(externalHost + ? { + includeProviderInventory: + ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), + includeLocalRegistry: false + } + : {}) }).catch((err) => { console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) }) diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts index f218f3a2c4f..22cf484a13e 100644 --- a/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts +++ b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts @@ -64,14 +64,23 @@ describe('stopMissingWorktreeTerminals', () => { const localProvider = createProvider([`${deletedId}@@local-session`]) const sshProvider = createProvider([`${deletedId}@@ssh-session`]) const getSshProvider = vi.fn(() => sshProvider) + const runtime = createRuntime() await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, [deletedId], [], { - runtime: createRuntime(), + runtime, getLocalProvider: () => localProvider, getSshProvider }) expect(getSshProvider).toHaveBeenCalledWith('ssh-1') + // The runtime graph holds both hosts' terminals under one id, so the sweep must fence to this one. + expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith( + deletedId, + expect.objectContaining({ + resolvedWorktreeId: deletedId, + resolvedConnectionId: 'ssh-1' + }) + ) expect(sshProvider.shutdown).toHaveBeenCalledWith( `${deletedId}@@ssh-session`, expect.objectContaining({ immediate: true }) @@ -95,7 +104,12 @@ describe('stopMissingWorktreeTerminals', () => { ) expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) - expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith(deletedId) + // The graph fallback still names the owning connection: this repo's inventory must not + // stop a same-id workspace's terminals on another host. + expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith(deletedId, { + resolvedWorktreeId: deletedId, + resolvedConnectionId: 'ssh-1' + }) }) // Why: an agent cleaning up workspaces deletes many at once. Enumerating the diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.ts index b25c4fc9f83..6c4f7f762a4 100644 --- a/src/main/runtime/missing-worktree-terminal-reconciliation.ts +++ b/src/main/runtime/missing-worktree-terminal-reconciliation.ts @@ -46,6 +46,18 @@ function withSharedProcessSnapshot(provider: IPtyProvider): IPtyProvider { }) } +// Why: `repoId::path` ids repeat across hosts, so a sweep driven by one repo's inventory +// must name its owner or it stops a same-id workspace's terminals on another host. +function hostFence( + repo: Repo, + worktreeId: string +): { resolvedWorktreeId: string; resolvedConnectionId?: string } { + return { + resolvedWorktreeId: worktreeId, + ...(repo.connectionId ? { resolvedConnectionId: repo.connectionId } : {}) + } +} + type MissingWorktreeTerminalReconciliationDeps = { runtime: OrcaRuntimeService getLocalProvider: () => IPtyProvider | null @@ -83,7 +95,7 @@ export async function stopMissingWorktreeTerminals( MISSING_WORKTREE_TEARDOWN_CONCURRENCY, async (worktreeId) => { try { - await deps.runtime.stopTerminalsForWorktree(worktreeId) + await deps.runtime.stopTerminalsForWorktree(worktreeId, hostFence(repo, worktreeId)) return worktreeId } catch { return null @@ -102,6 +114,7 @@ export async function stopMissingWorktreeTerminals( try { await killAllProcessesForWorktree(worktreeId, { runtime: deps.runtime, + ...hostFence(repo, worktreeId), localProvider: provider, onPtyStopped: deps.onPtyStopped, // Why: the shared process snapshot is only valid while nothing needs a diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6fb0c2386e3..d8c94367098 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5923,6 +5923,66 @@ describe('OrcaRuntimeService', () => { expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/remote/feature`) }) + // Regression: `repoId::path` ids repeat across hosts, so the SSH delete's runtime sweep used to + // stop the same-id local workspace's terminals too. + it('leaves a same-id local terminal running when the SSH copy is removed', async () => { + const remoteRepo = { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const remoteStore = { ...store, getRepos: () => [remoteRepo], getRepo: () => remoteRepo } + const gitProvider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined) + } + registerSshGitProvider('ssh-1', gitProvider as never) + const ptyProvider = { + listProcesses: vi.fn().mockResolvedValue([]), + shutdown: vi.fn().mockResolvedValue(undefined) + } + const runtime = new OrcaRuntimeService(remoteStore as never, undefined, { + getSshProvider: () => ptyProvider as never + }) + const stopAndWait = vi.fn(async () => true) + runtime.setPtyController({ + write: () => true, + kill: vi.fn(() => true), + stopAndWait, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, null) + runtime.registerPty('pty-remote', `${TEST_REPO_ID}::/remote/feature`, 'ssh-1') + runtime.registerPty('pty-local-same-id', `${TEST_REPO_ID}::/remote/feature`, null) + + try { + await runtime.removeManagedWorktree('path:/remote/feature', true, false) + } finally { + unregisterSshGitProvider('ssh-1') + } + + expect(stopAndWait).toHaveBeenCalledWith('pty-remote', expect.anything()) + expect(stopAndWait).not.toHaveBeenCalledWith('pty-local-same-id', expect.anything()) + }) + it('rejects SSH-backed runtime removal of the main worktree before provider deletion', async () => { const remoteStore = { ...store, @@ -34495,6 +34555,31 @@ describe('OrcaRuntimeService', () => { expect(kill).not.toHaveBeenCalled() }) + it('stops only the owning connection when one worktree id lives on two hosts', async () => { + const runtime = new OrcaRuntimeService(store) + const kill = vi.fn(() => true) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait: vi.fn(async () => true), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, null) + // The store keeps one `repoId::path` per host, so deleting the SSH copy must leave the + // local copy's terminals running — the fence the destructive removal paths now supply. + runtime.registerPty('pty-ssh', TEST_WORKTREE_ID, 'ssh-1') + runtime.registerPty('pty-local', TEST_WORKTREE_ID, null) + + await expect( + runtime.stopTerminalsForWorktree(TEST_WORKTREE_ID, { + resolvedWorktreeId: TEST_WORKTREE_ID, + resolvedConnectionId: 'ssh-1' + }) + ).resolves.toEqual({ stopped: 1 }) + expect(kill).toHaveBeenCalledWith('pty-ssh') + expect(kill).not.toHaveBeenCalledWith('pty-local') + }) + it('awaits physical PTY stop when destructive teardown supplies shared dedupe', async () => { const runtime = new OrcaRuntimeService(store) const physicalStop = makeDeferred() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 95d3cb99780..8cca2232985 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3387,6 +3387,10 @@ export class OrcaRuntimeService { } const teardownResult = await killAllProcessesForWorktree(worktreeId, { runtime: this, + // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id + // workspace's terminals on another connection (mirrors the IPC removal path). + resolvedWorktreeId: worktreeId, + ...(connectionId ? { resolvedConnectionId: connectionId } : {}), localProvider: provider, onPtyStopped: this.onPtyStopped ?? undefined, requirePhysicalStop: true, @@ -23438,14 +23442,34 @@ export class OrcaRuntimeService { 'Cannot delete the project root workspace. Remove the folder project instead.' ) } - const localProvider = this.getLocalProvider() - if (localProvider) { + // Folder projects can be SSH-backed, so resolve the owner before sweeping. + const folderHost = parseExecutionHostId( + store.getWorktreeMeta(removalTarget.id)?.hostId ?? getRepoExecutionHostId(repo) + ) + const folderSshPtyProvider = + folderHost?.kind === 'ssh' ? this.getSshProviderFn?.(folderHost.targetId) : undefined + const externalFolderHost = folderHost?.kind === 'ssh' || folderHost?.kind === 'runtime' + const folderPtyProvider = folderSshPtyProvider ?? this.getLocalProvider() + if (folderPtyProvider) { // Why: folder workspace deletion has no Git removal phase where PTYs // would otherwise be swept; tear them down before hiding the workspace. await killAllProcessesForWorktree(removalTarget.id, { runtime: this, - localProvider, - onPtyStopped: this.onPtyStopped ?? undefined + // External host inventories must never sweep a same-id local workspace. + resolvedWorktreeId: removalTarget.id, + ...(folderHost?.kind === 'ssh' ? { resolvedConnectionId: folderHost.targetId } : {}), + ...(folderHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: folderHost.environmentId } + : {}), + localProvider: folderPtyProvider, + onPtyStopped: this.onPtyStopped ?? undefined, + ...(externalFolderHost + ? { + includeProviderInventory: + folderHost?.kind === 'ssh' && Boolean(folderSshPtyProvider), + includeLocalRegistry: false + } + : {}) }).catch((err) => { console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) }) From 194e1a8d4dba2602e785c26b41fc67e53374482a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:18:40 -0700 Subject: [PATCH 015/101] fix(persistence): make the renderer unload checkpoint durably flush before reporting success (#12387) The sync before-unload checkpoint staged renderer state and then queued store.flushPendingAsync() fire-and-forget, so reload/restart/update paths navigated while the staged session, scrollback and UI state were still only in memory. Quit is covered by the will-quit flush barrier; those paths were not. Keep staging synchronous (no sync durable writes), but record the flush outcome and expose it on app:await-before-unload-checkpoint. Restart, updater install and lazy-chunk recovery reload now join that write before navigating and abort the attempt when it fails or outlives a 20s deadline. --- .../ipc/renderer-shutdown-checkpoint.test.ts | 124 +++++++++++++++--- src/main/ipc/renderer-shutdown-checkpoint.ts | 46 ++++++- src/main/persistence.ts | 6 +- src/preload/api-types.ts | 3 + src/preload/index.ts | 21 ++- src/preload/renderer-restart-wiring.test.ts | 25 +++- src/preload/renderer-restart-wiring.ts | 6 +- .../lib/lazy-chunk-recovery-reload.test.ts | 39 ++++++ .../src/lib/lazy-chunk-recovery-reload.ts | 8 +- src/renderer/src/web/web-preload-api.ts | 2 + .../renderer-restart-preparation.test.ts | 50 ++++++- src/shared/renderer-restart-preparation.ts | 7 +- 12 files changed, 300 insertions(+), 37 deletions(-) create mode 100644 src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts diff --git a/src/main/ipc/renderer-shutdown-checkpoint.test.ts b/src/main/ipc/renderer-shutdown-checkpoint.test.ts index 78accf8abe1..76fc0509eea 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.test.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { syncHandlers } = vi.hoisted(() => ({ +const { syncHandlers, invokeHandlers } = vi.hoisted(() => ({ syncHandlers: new Map< string, (event: { returnValue?: unknown }, args: Record) => void - >() + >(), + invokeHandlers: new Map Promise<{ ok: boolean }>>() })) vi.mock('electron', () => ({ @@ -16,15 +17,25 @@ vi.mock('electron', () => ({ ) => { syncHandlers.set(channel, handler) } - ) + ), + handle: vi.fn((channel: string, handler: () => Promise<{ ok: boolean }>) => { + invokeHandlers.set(channel, handler) + }) } })) -import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint' +import { + registerRendererShutdownCheckpointHandler, + SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS +} from './renderer-shutdown-checkpoint' + +const AWAIT_CHANNEL = 'app:await-before-unload-checkpoint' describe('registerRendererShutdownCheckpointHandler', () => { beforeEach(() => { syncHandlers.clear() + invokeHandlers.clear() + vi.restoreAllMocks() }) it('stages every shutdown mutation before queueing persistence', () => { @@ -34,7 +45,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { callOrder.push(`session:${hostId ?? 'local'}`) }), updateUI: vi.fn(() => callOrder.push('ui')), - flushPendingAsync: vi.fn(() => { + flushPendingOrThrowAsync: vi.fn(() => { callOrder.push('persist') return Promise.resolve() }) @@ -62,7 +73,11 @@ describe('registerRendererShutdownCheckpointHandler', () => { 'runtime:host-1' ) expect(store.updateUI).toHaveBeenCalledWith({ activeView: 'settings' }) - expect(store.flushPendingAsync).toHaveBeenCalledTimes(1) + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + // Why: a live app keeps mutating state, so draining to a stable generation would livelock. + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledWith( + expect.objectContaining({ drainToStableGeneration: false }) + ) expect(callOrder).toEqual(['session:local', 'session:runtime:host-1', 'ui', 'persist']) expect(event.returnValue).toEqual({ ok: true }) }) @@ -73,7 +88,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { updateUI: vi.fn(() => { throw new Error('disk full') }), - flushPendingAsync: vi.fn(() => Promise.resolve()) + flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) } registerRendererShutdownCheckpointHandler(store as never) @@ -84,11 +99,11 @@ describe('registerRendererShutdownCheckpointHandler', () => { expect(event.returnValue).toEqual({ ok: false }) }) - it('does not queue persistence when staging is incomplete', () => { + it('does not queue persistence when staging is incomplete', async () => { const store = { stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), - flushPendingAsync: vi.fn(() => Promise.resolve()) + flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) } registerRendererShutdownCheckpointHandler(store as never) @@ -99,19 +114,16 @@ describe('registerRendererShutdownCheckpointHandler', () => { const event: { returnValue?: unknown } = {} handler?.(event, { sessions: [], ui: { activeView: 'settings' } }) - expect(store.flushPendingAsync).not.toHaveBeenCalled() + expect(store.flushPendingOrThrowAsync).not.toHaveBeenCalled() expect(event.returnValue).toEqual({ ok: false }) + await expect(invokeHandlers.get(AWAIT_CHANNEL)?.()).resolves.toEqual({ ok: false }) }) - it('returns before the asynchronous persistence settles', () => { - let resolve!: () => void - const pending = new Promise((next) => { - resolve = next - }) + it('stages synchronously without waiting on the durable write', () => { const store = { stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), - flushPendingAsync: vi.fn(() => pending) + flushPendingOrThrowAsync: vi.fn(() => new Promise(() => {})) } registerRendererShutdownCheckpointHandler(store as never) @@ -120,6 +132,84 @@ describe('registerRendererShutdownCheckpointHandler', () => { handler?.(event, { sessions: [], ui: { activeView: 'settings' } }) expect(event.returnValue).toEqual({ ok: true }) - resolve() + }) + + it('holds the checkpoint open until the durable write settles', async () => { + let resolveFlush!: () => void + const store = { + stageWorkspaceSessionBeforeUnload: vi.fn(), + updateUI: vi.fn(), + flushPendingOrThrowAsync: vi.fn( + () => + new Promise((next) => { + resolveFlush = next + }) + ) + } + registerRendererShutdownCheckpointHandler(store as never) + + syncHandlers.get('app:stage-before-unload-sync')?.({}, { sessions: [], ui: {} }) + const checkpoint = invokeHandlers.get(AWAIT_CHANNEL)?.() + let settled: unknown = 'pending' + void checkpoint?.then((result) => { + settled = result + }) + + await Promise.resolve() + expect(settled).toBe('pending') + + resolveFlush() + await expect(checkpoint).resolves.toEqual({ ok: true }) + }) + + it('reports a failed durable write instead of a successful checkpoint', async () => { + const store = { + stageWorkspaceSessionBeforeUnload: vi.fn(), + updateUI: vi.fn(), + flushPendingOrThrowAsync: vi.fn(() => Promise.reject(new Error('disk full'))) + } + vi.spyOn(console, 'error').mockImplementation(() => {}) + registerRendererShutdownCheckpointHandler(store as never) + + const event: { returnValue?: unknown } = {} + syncHandlers.get('app:stage-before-unload-sync')?.(event, { sessions: [], ui: {} }) + + expect(event.returnValue).toEqual({ ok: true }) + await expect(invokeHandlers.get(AWAIT_CHANNEL)?.()).resolves.toEqual({ ok: false }) + }) + + it('fails the checkpoint when the durable write outlives its deadline', async () => { + const store = { + stageWorkspaceSessionBeforeUnload: vi.fn(), + updateUI: vi.fn(), + flushPendingOrThrowAsync: vi.fn( + (_options: { signal: AbortSignal }) => new Promise(() => {}) + ) + } + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.useFakeTimers() + try { + registerRendererShutdownCheckpointHandler(store as never) + syncHandlers.get('app:stage-before-unload-sync')?.({}, { sessions: [], ui: {} }) + const checkpoint = invokeHandlers.get(AWAIT_CHANNEL)?.() + + await vi.advanceTimersByTimeAsync(SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS) + + await expect(checkpoint).resolves.toEqual({ ok: false }) + expect(store.flushPendingOrThrowAsync.mock.calls[0]?.[0]?.signal.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('reports success before any checkpoint is staged', async () => { + const store = { + stageWorkspaceSessionBeforeUnload: vi.fn(), + updateUI: vi.fn(), + flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) + } + registerRendererShutdownCheckpointHandler(store as never) + + await expect(invokeHandlers.get(AWAIT_CHANNEL)?.()).resolves.toEqual({ ok: true }) }) }) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index a1fafa16634..e875f950d75 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -8,7 +8,42 @@ type StageBeforeUnloadSyncArgs = { ui: Partial } +export type ShutdownCheckpointResult = { ok: boolean } + +/** Matches the will-quit teardown budget so a stalled disk can't strand a restart. */ +export const SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS = 20_000 + +function flushStagedStateWithDeadline(store: Store): Promise { + const controller = new AbortController() + let timer: ReturnType | null = null + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort() + console.error('[app] Timed out persisting staged renderer state') + resolve({ ok: false }) + }, SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS) + }) + // Why not drain to a stable generation: the staged snapshot lands in the first + // write, and a live app keeps mutating state, which would livelock the drain. + const flush = store + .flushPendingOrThrowAsync({ signal: controller.signal, drainToStableGeneration: false }) + .then((): ShutdownCheckpointResult => ({ ok: true })) + .catch((error): ShutdownCheckpointResult => { + console.error('[app] Failed to persist staged renderer state:', error) + return { ok: false } + }) + return Promise.race([flush, deadline]).finally(() => { + if (timer) { + clearTimeout(timer) + } + }) +} + export function registerRendererShutdownCheckpointHandler(store: Store): void { + // Why: beforeunload cannot await, so the sync reply only reports staging. + // Durability is joined out-of-band by paths that are about to navigate. + let pendingCheckpoint: Promise = Promise.resolve({ ok: true }) + ipcMain.on('app:stage-before-unload-sync', (event, args: StageBeforeUnloadSyncArgs) => { let ok = true try { @@ -20,11 +55,12 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void { console.error('[app] Failed to stage renderer state before unload:', error) ok = false } - if (ok) { - void store.flushPendingAsync().catch((error) => { - console.error('[app] Failed to persist staged renderer state:', error) - }) - } + pendingCheckpoint = ok ? flushStagedStateWithDeadline(store) : Promise.resolve({ ok: false }) event.returnValue = { ok } }) + + ipcMain.handle( + 'app:await-before-unload-checkpoint', + (): Promise => pendingCheckpoint + ) } diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 40660eb8a1c..077d9b871a7 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -7291,11 +7291,13 @@ export class Store { return this.flushCurrentStateAsync(false, undefined, false).catch(() => {}) } - flushPendingOrThrowAsync(options: { signal?: AbortSignal } = {}): Promise { + flushPendingOrThrowAsync( + options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} + ): Promise { if (this.writesFrozen || this.quitFlushStarted) { return Promise.reject(new Error('Cannot flush while persistence is finalized')) } - return this.flushCurrentStateAsync(false, options.signal) + return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration) } // Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2acb211c3b0..7f60af7e8e3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -990,6 +990,9 @@ export type AppApi = { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] ui: Partial }) => void + /** Resolves once the last staged checkpoint is durably written; rejects if that + * write failed, so a reload/restart can abort instead of losing the snapshot. */ + awaitBeforeUnloadCheckpoint: () => Promise /** Resolves when the daemon PTY provider and hook receiver have either * started or failed open for the first BrowserWindow. */ awaitFirstWindowStartupServices: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index c6bbf3739e1..de829aafe37 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -290,6 +290,17 @@ import { registerRendererRestartIpcRelays } from './renderer-restart-wiring' +// Why: the sync checkpoint only stages; this joins its durable write so a +// navigating path can abort instead of losing the staged session. +async function awaitBeforeUnloadCheckpoint(): Promise { + const result = (await ipcRenderer.invoke('app:await-before-unload-checkpoint')) as { + ok?: unknown + } + if (result?.ok !== true) { + throw new Error('Failed to persist renderer state before unload.') + } +} + type NativeFileDropCallback = (data: NativeFileDropPayload) => void const nativeFileDropCallbacks: NativeFileDropCallback[] = [] @@ -479,7 +490,8 @@ const api = { restart: async (): Promise => { await prepareRendererForAppRestart(window, { startedEventName: ORCA_APP_RESTART_STARTED_EVENT, - abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + awaitCheckpoint: awaitBeforeUnloadCheckpoint }) try { return await ipcRenderer.invoke('app:restart') @@ -2974,8 +2986,11 @@ const api = { showLinuxPackage: () => ipcRenderer.invoke('updater:showLinuxPackage'), listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), quitAndInstall: (): Promise => - prepareAndInvokeUpdaterInstall(window, updaterQuitAbortRelay, () => - ipcRenderer.invoke('updater:quitAndInstall') + prepareAndInvokeUpdaterInstall( + window, + updaterQuitAbortRelay, + () => ipcRenderer.invoke('updater:quitAndInstall'), + awaitBeforeUnloadCheckpoint ), onStatus: (callback) => { diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index 56cd5d3dd9e..0198c72a44f 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -44,10 +44,27 @@ describe('renderer restart wiring', () => { throw new Error('IPC failed') }) - await expect(prepareAndInvokeUpdaterInstall(eventTarget, relay, invoke)).rejects.toThrow( - 'IPC failed' - ) + await expect( + prepareAndInvokeUpdaterInstall(eventTarget, relay, invoke, async () => { + calls.push('checkpoint-flushed') + }) + ).rejects.toThrow('IPC failed') - expect(calls).toEqual(['prepared', 'marked', 'invoked', 'aborted']) + expect(calls).toEqual(['prepared', 'checkpoint-flushed', 'marked', 'invoked', 'aborted']) + }) + + it('never installs the update when the shutdown checkpoint fails to persist', async () => { + const eventTarget = new EventTarget() + const invoke = vi.fn(() => Promise.resolve()) + const relay = { markPrepared: vi.fn(), abort: vi.fn() } + + await expect( + prepareAndInvokeUpdaterInstall(eventTarget, relay, invoke, () => + Promise.reject(new Error('Failed to persist renderer state before unload.')) + ) + ).rejects.toThrow('Failed to persist renderer state before unload.') + + expect(invoke).not.toHaveBeenCalled() + expect(relay.markPrepared).not.toHaveBeenCalled() }) }) diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index fbd3e26a71d..254d386403a 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -26,11 +26,13 @@ export function registerRendererRestartIpcRelays( export async function prepareAndInvokeUpdaterInstall( eventTarget: EventTarget, relay: Pick, - invoke: () => Promise + invoke: () => Promise, + awaitCheckpoint: () => Promise ): Promise { await prepareRendererForAppRestart(eventTarget, { startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, - abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT + abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, + awaitCheckpoint }) relay.markPrepared() try { diff --git a/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts b/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts new file mode 100644 index 00000000000..9cdf4fa728d --- /dev/null +++ b/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' +import { requestLazyChunkRecoveryReload } from './lazy-chunk-recovery-reload' + +describe('requestLazyChunkRecoveryReload', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('refuses the reload when the staged checkpoint never reaches disk', async () => { + const reload = vi.spyOn(window.location, 'reload').mockImplementation(() => undefined) + + await expect( + requestLazyChunkRecoveryReload(window, () => + Promise.reject(new Error('Failed to persist renderer state before unload.')) + ) + ).resolves.toBe('checkpoint-refused') + + expect(reload).not.toHaveBeenCalled() + }) + + it('navigates only after the checkpoint is durably written', async () => { + const order: string[] = [] + vi.spyOn(window.location, 'reload').mockImplementation(() => { + order.push('reload') + // A real landed reload destroys the document; veto so the wait settles. + window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) + }) + + await expect( + requestLazyChunkRecoveryReload(window, async () => { + order.push('flushed') + }) + ).resolves.toBe('unload-vetoed') + + expect(order).toEqual(['flushed', 'reload']) + }) +}) diff --git a/src/renderer/src/lib/lazy-chunk-recovery-reload.ts b/src/renderer/src/lib/lazy-chunk-recovery-reload.ts index 5bc259a7ae4..64e78fc283a 100644 --- a/src/renderer/src/lib/lazy-chunk-recovery-reload.ts +++ b/src/renderer/src/lib/lazy-chunk-recovery-reload.ts @@ -49,12 +49,16 @@ function waitForRefusedNavigation(win: Window): RefusedNavigationWait { /** Resolves only if the navigation is refused; a landed reload destroys this document. */ export async function requestLazyChunkRecoveryReload( - win: Window + win: Window, + // Hosts without a preload bridge stage durably in-process; nothing to join. + awaitCheckpoint: () => Promise = () => + window.api?.app?.awaitBeforeUnloadCheckpoint?.() ?? Promise.resolve() ): Promise { try { await prepareRendererForAppRestart(win, { startedEventName: ORCA_APP_RESTART_STARTED_EVENT, - abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + awaitCheckpoint }) } catch { // Never reload over editor buffers that could not be backed up. diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index dc9903bea2d..6199c35b831 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -556,6 +556,8 @@ function createWebPreloadApi(): Partial { } writeJson(UI_STORAGE_KEY, mergeWebUIState(readLocalWebUIState(), ui)) }, + // Staging already wrote through to browser storage, so there is nothing left to join. + awaitBeforeUnloadCheckpoint: () => Promise.resolve(), awaitFirstWindowStartupServices: () => Promise.resolve(), recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), startupDiagnostic: () => Promise.resolve(), diff --git a/src/shared/renderer-restart-preparation.test.ts b/src/shared/renderer-restart-preparation.test.ts index 02665442694..925aedbf845 100644 --- a/src/shared/renderer-restart-preparation.test.ts +++ b/src/shared/renderer-restart-preparation.test.ts @@ -18,7 +18,8 @@ describe('prepareRendererForAppRestart', () => { await expect( prepareRendererForAppRestart(eventTarget, { startedEventName: 'restart-started', - abortedEventName: 'restart-aborted' + abortedEventName: 'restart-aborted', + awaitCheckpoint: () => Promise.resolve() }) ).rejects.toThrow('Renderer shutdown checkpoint was not completed.') @@ -26,6 +27,53 @@ describe('prepareRendererForAppRestart', () => { expect(checkpoint).toHaveBeenCalledTimes(1) expect(aborted).toHaveBeenCalledTimes(1) }) + + it('waits for the durable checkpoint write before the restart proceeds', async () => { + const eventTarget = new EventTarget() + const order: string[] = [] + let releaseCheckpoint!: () => void + eventTarget.addEventListener('beforeunload', () => order.push('staged')) + + const prepared = prepareRendererForAppRestart(eventTarget, { + startedEventName: 'restart-started', + abortedEventName: 'restart-aborted', + awaitCheckpoint: () => + new Promise((resolve) => { + order.push('awaiting-flush') + releaseCheckpoint = () => { + order.push('flushed') + resolve() + } + }) + }) + let settled = false + void prepared.then(() => { + settled = true + }) + + await Promise.resolve() + expect(settled).toBe(false) + + releaseCheckpoint() + await prepared + expect(order).toEqual(['staged', 'awaiting-flush', 'flushed']) + }) + + it('aborts the restart when the staged state cannot be persisted', async () => { + const eventTarget = new EventTarget() + const aborted = vi.fn() + eventTarget.addEventListener('restart-aborted', aborted) + + await expect( + prepareRendererForAppRestart(eventTarget, { + startedEventName: 'restart-started', + abortedEventName: 'restart-aborted', + awaitCheckpoint: () => Promise.reject(new Error('Failed to persist renderer state.')) + }) + ).rejects.toThrow('Failed to persist renderer state.') + + expect(aborted).toHaveBeenCalledTimes(1) + }) }) describe('createUpdaterQuitAbortRelay', () => { diff --git a/src/shared/renderer-restart-preparation.ts b/src/shared/renderer-restart-preparation.ts index 78560dd761e..19aeb20ff44 100644 --- a/src/shared/renderer-restart-preparation.ts +++ b/src/shared/renderer-restart-preparation.ts @@ -7,6 +7,8 @@ import type { UpdateStatus } from './types' export type AppRestartPrepOptions = { startedEventName: string abortedEventName: string + /** Joins the durable write of the state the checkpoint staged; rejects if it failed. */ + awaitCheckpoint: () => Promise } function requestEditorHotExitBackup(eventTarget: EventTarget): Promise { @@ -36,7 +38,7 @@ function requestEditorHotExitBackup(eventTarget: EventTarget): Promise { export async function prepareRendererForAppRestart( eventTarget: EventTarget, - { startedEventName, abortedEventName }: AppRestartPrepOptions + { startedEventName, abortedEventName, awaitCheckpoint }: AppRestartPrepOptions ): Promise { eventTarget.dispatchEvent(new Event(startedEventName)) @@ -48,6 +50,9 @@ export async function prepareRendererForAppRestart( if (!accepted) { throw new Error('Renderer shutdown checkpoint was not completed.') } + // Why: the checkpoint only stages synchronously. Navigating before that + // write lands loses the session snapshot to a crash or power loss. + await awaitCheckpoint() } catch (error) { eventTarget.dispatchEvent(new Event(abortedEventName)) throw error From 64b81cde501b844b3f323e8e4f3e2867d8aa8968 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:22:49 -0700 Subject: [PATCH 016/101] perf(runtime): memoize mobile snapshots for mounted terminal worktrees (#12383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-worktree rebuild memo from #12207 refused to skip any worktree with a registered TerminalPane, because builders read live PaneManager/DOM state the memo could not witness. Hidden worktrees stay mounted (8 parked + 12 retained by default, unbounded with terminalHiddenViewParking off), so that set rebuilt on every publication — on the always-on hosts the memo targeted, ~20 worktrees paid the full content build plus the new inputs build and deep compare every tick. Capture the live reads instead: snapshot pane leaf ids, the live active leaf, the serialized pane tree, and per-pane numeric/pty bindings once per publication into MountedTerminalSurfaceCapture, compare captures by value in the memo, and have the builders read only the capture. Live state outside the capture is now unrepresentable in a builder, so the skip stays provably complete — and a mounted worktree whose panes did not change reuses its snapshot like any other. --- ...ync-runtime-graph-publication-cost.test.ts | 174 +++++++++++++++++- .../src/runtime/sync-runtime-graph.ts | 152 ++++++++++++--- 2 files changed, 298 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts b/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts index 0cd0793a266..3c285761cf5 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildMobileSessionTabSnapshots } from './sync-runtime-graph' +import { buildMobileSessionTabSnapshots, registerRuntimeTerminalTab } from './sync-runtime-graph' import type { AppState } from '../store/types' // Why: getBrowserTabsByWorktree reads this slice once per worktree inside the build loop, @@ -207,3 +207,175 @@ describe('mobile session publication cost', () => { expect(rebuiltWorktrees).toEqual(['repo::/title-wt-7']) }) }) + +// Distinct ids per test: the snapshot memo is module state shared in this file. +const MOUNTED_WT = 'repo::/mounted-wt-0' +const CONTROL_WT = 'repo::/mounted-wt-1' +const MOUNTED_LEAF_ID = 'aaaaaaaa-1111-4111-8111-111111111111' +const SPLIT_LEAF_ID = 'bbbbbbbb-1111-4111-8111-111111111111' + +function makeMountedState(tabIdPrefix: string): { + state: AppState + mountedTabId: string + titleReads: () => number + resetTitleReads: () => void +} { + let titleReads = 0 + const mountedTabId = `${tabIdPrefix}-term-0` + const state = { + tabsByWorktree: { + [MOUNTED_WT]: [ + { + id: mountedTabId, + customTitle: null, + ptyId: null, + get title() { + titleReads++ + return 'Agent mounted' + } + } + ], + [CONTROL_WT]: [ + { id: `${tabIdPrefix}-term-1`, title: 'Agent control', customTitle: null, ptyId: null } + ] + }, + terminalLayoutsByTabId: { + [mountedTabId]: { + root: { type: 'leaf', leafId: MOUNTED_LEAF_ID }, + activeLeafId: MOUNTED_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [MOUNTED_LEAF_ID]: 'pty-saved-1' } + } + }, + runtimePaneTitlesByTabId: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + agentStatusByPaneKey: {}, + browserTabsByWorktree: {} + } as unknown as AppState + return { + state, + mountedTabId, + titleReads: () => titleReads, + resetTitleReads: () => { + titleReads = 0 + } + } +} + +function makeLiveSurface(tabId: string): { + registration: Parameters[0] + setPtyId: (paneId: number, ptyId: string) => void + addPane: (paneId: number, leafId: string) => void +} { + const panes = [{ id: 1, leafId: MOUNTED_LEAF_ID }] + const ptyIdByPaneId = new Map([[1, 'pty-live-1']]) + const manager = { + getPanes: () => panes.map((pane) => ({ ...pane })), + getActivePane: () => panes[0] ?? null, + getLeafId: (paneId: number) => panes.find((pane) => pane.id === paneId)?.leafId ?? null, + getNumericIdForLeaf: (leafId: string) => + panes.find((pane) => pane.leafId === leafId)?.id ?? null + } + const registration = { + tabId, + worktreeId: MOUNTED_WT, + getManager: () => manager, + getContainer: () => null, + getPtyIdForPane: (paneId: number) => ptyIdByPaneId.get(paneId) ?? null + } as unknown as Parameters[0] + return { + registration, + setPtyId: (paneId, ptyId) => { + ptyIdByPaneId.set(paneId, ptyId) + }, + addPane: (paneId, leafId) => { + panes.push({ id: paneId, leafId }) + ptyIdByPaneId.set(paneId, `pty-live-${paneId}`) + } + } +} + +function snapshotsByWorktree( + state: AppState +): Map[number]> { + return new Map( + buildMobileSessionTabSnapshots(state).map((snapshot) => [snapshot.worktree, snapshot]) + ) +} + +describe('mounted terminal surface memoization', () => { + it('reuses a mounted worktree snapshot when its live pane state is unchanged', () => { + const { state, mountedTabId, titleReads, resetTitleReads } = makeMountedState('mounted-a') + const unregister = registerRuntimeTerminalTab(makeLiveSurface(mountedTabId).registration) + try { + const before = snapshotsByWorktree(state) + resetTitleReads() + + const after = snapshotsByWorktree(state) + + expect(titleReads()).toBe(0) + expect(after.get(MOUNTED_WT)).toBe(before.get(MOUNTED_WT)) + } finally { + unregister() + } + }) + + it('publishes a live pty rebinding on a mounted worktree', () => { + const { state, mountedTabId } = makeMountedState('mounted-b') + const surface = makeLiveSurface(mountedTabId) + const unregister = registerRuntimeTerminalTab(surface.registration) + try { + const before = snapshotsByWorktree(state) + + surface.setPtyId(1, 'pty-live-2') + const after = snapshotsByWorktree(state) + + expect(after.get(MOUNTED_WT)).not.toBe(before.get(MOUNTED_WT)) + expect(after.get(MOUNTED_WT)?.tabs).toEqual([ + expect.objectContaining({ ptyId: 'pty-live-2' }) + ]) + expect(after.get(CONTROL_WT)).toBe(before.get(CONTROL_WT)) + } finally { + unregister() + } + }) + + it('publishes a live pane split on a mounted worktree', () => { + const { state, mountedTabId } = makeMountedState('mounted-c') + const surface = makeLiveSurface(mountedTabId) + const unregister = registerRuntimeTerminalTab(surface.registration) + try { + snapshotsByWorktree(state) + + surface.addPane(2, SPLIT_LEAF_ID) + const after = snapshotsByWorktree(state) + + expect( + after.get(MOUNTED_WT)?.tabs.map((tab) => (tab.type === 'terminal' ? tab.leafId : null)) + ).toEqual([MOUNTED_LEAF_ID, SPLIT_LEAF_ID]) + } finally { + unregister() + } + }) + + it('falls back to the persisted pty binding after the surface unmounts', () => { + const { state, mountedTabId, titleReads, resetTitleReads } = makeMountedState('mounted-d') + const unregister = registerRuntimeTerminalTab(makeLiveSurface(mountedTabId).registration) + snapshotsByWorktree(state) + + unregister() + resetTitleReads() + const after = snapshotsByWorktree(state) + + expect(titleReads()).toBeGreaterThan(0) + expect(after.get(MOUNTED_WT)?.tabs).toEqual([expect.objectContaining({ ptyId: 'pty-saved-1' })]) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 0f8e28b7fe6..1966bed1df1 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -31,6 +31,7 @@ import type { TabGroup, TabGroupLayoutNode, TerminalLayoutSnapshot, + TerminalPaneLayoutNode, TerminalTab } from '../../../shared/types' import { resolveTerminalTabTitle } from '../../../shared/tab-title-resolution' @@ -92,6 +93,22 @@ type MobileSessionPublicationInputs = { generatedTitlesEnabled: boolean terminalTheme: RuntimeMobileTerminalTheme | undefined } +/** + * Live PaneManager/DOM reads for one mounted terminal tab, captured once per + * publication. + * + * Why: builders read this instead of the registry, so live state the memo + * cannot witness is unrepresentable — mounted worktrees memoize like the rest + * instead of rebuilding on every publication. + */ +type MountedTerminalSurfaceCapture = { + paneLeafIds: readonly string[] + hasLiveActivePane: boolean + liveActiveLeafId: string | null + liveLayoutRoot: TerminalPaneLayoutNode | null + numericPaneIdByLeafId: ReadonlyMap + ptyIdByNumericPaneId: ReadonlyMap +} /** * One worktree's complete mobile-snapshot input set. * @@ -131,8 +148,7 @@ type MobileSessionWorktreeInputs = { activeBrowserWorkspaceId: string | null generatedTitlesEnabled: boolean terminalTheme: RuntimeMobileTerminalTheme | undefined - // Why: a mounted TerminalPane feeds live DOM/PaneManager state that no store reference can witness. - hasMountedTerminalSurface: boolean + mountedSurfaceCaptureByTabId: ReadonlyMap } const registeredTabs = new Map() @@ -1026,11 +1042,99 @@ function buildMobileSessionWorktreeInputs( activeBrowserWorkspaceId: state.activeBrowserTabIdByWorktree?.[worktreeId] ?? null, generatedTitlesEnabled: publication.generatedTitlesEnabled, terminalTheme: publication.terminalTheme, - hasMountedTerminalSurface: terminalTabs.some((tab) => registeredTabs.has(tab.id)) + mountedSurfaceCaptureByTabId: captureMountedTerminalSurfaces( + terminalTabs, + state.terminalLayoutsByTabId + ) } } -function narrowedEntriesEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { +function captureMountedTerminalSurfaces( + terminalTabs: AppState['tabsByWorktree'][string], + terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] +): ReadonlyMap { + let captures: Map | null = null + for (const tab of terminalTabs) { + const registered = registeredTabs.get(tab.id) + if (!registered) { + continue + } + captures ??= new Map() + captures.set(tab.id, captureMountedTerminalSurface(registered, terminalLayoutsByTabId[tab.id])) + } + return captures ?? EMPTY_NARROWED_BY_KEY +} + +function captureMountedTerminalSurface( + registered: RegisteredTerminalTab, + savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined +): MountedTerminalSurfaceCapture { + const manager = registered.getManager() + const paneLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] + const activePane = manager?.getActivePane() ?? null + const firstChild = registered.getContainer()?.firstElementChild + // Why: mirrors getRuntimeLeafIdsForTerminal so every leaf the builder resolves has a captured pane id. + const effectiveLeafIds = + paneLeafIds.length > 0 + ? paneLeafIds + : collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) + const numericPaneIdByLeafId = new Map() + const ptyIdByNumericPaneId = new Map() + for (const leafId of effectiveLeafIds) { + const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null + numericPaneIdByLeafId.set(leafId, numericPaneId) + if (numericPaneId !== null) { + ptyIdByNumericPaneId.set(numericPaneId, registered.getPtyIdForPane(numericPaneId)) + } + } + return { + paneLeafIds, + hasLiveActivePane: activePane !== null, + liveActiveLeafId: activePane !== null ? (manager?.getLeafId(activePane.id) ?? null) : null, + liveLayoutRoot: serializePaneTree( + typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null + ), + numericPaneIdByLeafId, + ptyIdByNumericPaneId + } +} + +function mountedTerminalSurfaceCaptureEquals( + a: MountedTerminalSurfaceCapture, + b: MountedTerminalSurfaceCapture +): boolean { + return ( + a.hasLiveActivePane === b.hasLiveActivePane && + a.liveActiveLeafId === b.liveActiveLeafId && + a.paneLeafIds.length === b.paneLeafIds.length && + a.paneLeafIds.every((leafId, index) => b.paneLeafIds[index] === leafId) && + narrowedEntriesEqual(a.numericPaneIdByLeafId, b.numericPaneIdByLeafId) && + narrowedEntriesEqual(a.ptyIdByNumericPaneId, b.ptyIdByNumericPaneId) && + // Why: serialization allocates a fresh tree per capture, so it compares by content. + jsonContentEquals(a.liveLayoutRoot, b.liveLayoutRoot) + ) +} + +function mountedSurfaceCapturesEqual( + a: ReadonlyMap, + b: ReadonlyMap +): boolean { + if (a === b) { + return true + } + if (a.size !== b.size) { + return false + } + for (const [tabId, capture] of a) { + const other = b.get(tabId) + if (!other || !mountedTerminalSurfaceCaptureEquals(capture, other)) { + return false + } + } + return true +} + +function narrowedEntriesEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { if (a === b) { return true } @@ -1056,9 +1160,6 @@ function canReuseMobileSessionSnapshot( next: MobileSessionWorktreeInputs ): boolean { return ( - // Why: live DOM/PaneManager state is invisible to store references, so a mounted surface always rebuilds. - !previous.hasMountedTerminalSurface && - !next.hasMountedTerminalSurface && previous.worktreeId === next.worktreeId && previous.terminalTabs === next.terminalTabs && previous.browserWorkspaces === next.browserWorkspaces && @@ -1084,6 +1185,12 @@ function canReuseMobileSessionSnapshot( narrowedEntriesEqual( previous.certificateFailureByBrowserPageId, next.certificateFailureByBrowserPageId + ) && + // Why: live DOM/PaneManager state is invisible to store references; the + // capture makes it comparable instead of forcing mounted worktrees to rebuild. + mountedSurfaceCapturesEqual( + previous.mountedSurfaceCaptureByTabId, + next.mountedSurfaceCaptureByTabId ) ) } @@ -1683,12 +1790,10 @@ function resolveMobileTerminalTheme( } function getRuntimeLeafIdsForTerminal( - tabId: string, + capture: MountedTerminalSurfaceCapture | undefined, savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined -): string[] { - const registered = registeredTabs.get(tabId) - const manager = registered?.getManager() - const liveLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] +): readonly string[] { + const liveLeafIds = capture?.paneLeafIds ?? [] if (liveLeafIds.length > 0) { return liveLeafIds } @@ -1707,18 +1812,15 @@ function buildMobileTerminalSurfaceTabs( terminal: NonNullable[number], unifiedTabId?: string ): RuntimeMobileSessionSnapshotTab[] { - const registered = registeredTabs.get(terminal.id) + const capture = inputs.mountedSurfaceCaptureByTabId.get(terminal.id) const isDesktopTabActive = unifiedTabId ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) : inputs.activeTerminalTabId === terminal.id - const manager = registered?.getManager() - const liveActivePaneId = manager?.getActivePane()?.id ?? null const savedLayout = inputs.terminalLayoutByTabId.get(terminal.id) - const leafIds = getRuntimeLeafIdsForTerminal(terminal.id, savedLayout) - const activeLeafId = - liveActivePaneId !== null - ? (manager?.getLeafId(liveActivePaneId) ?? null) - : (savedLayout?.activeLeafId ?? leafIds[0] ?? null) + const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout) + const activeLeafId = capture?.hasLiveActivePane + ? capture.liveActiveLeafId + : (savedLayout?.activeLeafId ?? leafIds[0] ?? null) const paneTitles = inputs.paneTitlesByTabId.get(terminal.id) ?? {} const generatedTitlesEnabled = inputs.generatedTitlesEnabled const sanitizedSavedLayout = savedLayout @@ -1736,11 +1838,7 @@ function buildMobileTerminalSurfaceTabs( ? seededLaunchDraft : null const publishedLaunchDraft = launchDraftEntry?.text.trim() ? launchDraftEntry : null - const container = registered?.getContainer() - const firstChild = container?.firstElementChild - const liveLayoutRoot = serializePaneTree( - typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null - ) + const liveLayoutRoot = capture?.liveLayoutRoot ?? null const parentLayout = normalizeTerminalLayoutSnapshot({ // Why: live DOM tree is authoritative when mounted, else the saved tree; synthesize only as a last resort, never re-guess. root: resolveTerminalLayoutRoot({ @@ -1760,11 +1858,11 @@ function buildMobileTerminalSurfaceTabs( : {}) } satisfies TerminalLayoutSnapshot).snapshot return leafIds.map((leafId) => { - const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null + const numericPaneId = capture?.numericPaneIdByLeafId.get(leafId) ?? null const ptyId = numericPaneId === null ? (savedPtyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? terminal.ptyId : null)) - : (registered?.getPtyIdForPane(numericPaneId) ?? savedPtyIdsByLeafId[leafId] ?? null) + : (capture?.ptyIdByNumericPaneId.get(numericPaneId) ?? savedPtyIdsByLeafId[leafId] ?? null) const legacyPaneId = numericPaneId === null ? /^pane:(\d+)$/.exec(leafId)?.[1] : null const paneTitle = numericPaneId !== null From a69ca6d5b75f65c5fa1ec16cb88c02046ed758bf Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:23:22 -0700 Subject: [PATCH 017/101] fix(ui): always show Onboarding and Restart Orca in Help (#12379) * fix(ui): always show Onboarding in the Help menu Users could not find "Show onboarding again" because it required holding Alt. Surface it on the default Help menu so replay is discoverable without reinstalling. * fix(ui): always show Restart Orca in the Help menu Surface Restart next to Check for Updates by default and drop the Alt-only admin gate, which no longer hid anything else. --- .../settings/GeneralUpdateSettingsSection.tsx | 4 +- .../sidebar/SidebarSettingsHelpMenu.test.tsx | 9 ++- .../sidebar/SidebarSettingsHelpMenu.tsx | 56 +++++++------------ 3 files changed, 28 insertions(+), 41 deletions(-) diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 1b325ed4766..15677927ad3 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -42,8 +42,8 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { const [appVersion, setAppVersion] = useState(null) const updateCheckHint = getUpdateCheckHint() // Why: channel switching is a power-user escape hatch that can downgrade the app - // onto an unvetted build. Option/Alt-clicking the header reveals it, matching the - // Help menu's hidden admin options rather than shipping it on the default surface. + // onto an unvetted build. Option/Alt-clicking the header reveals it rather than + // shipping it on the default surface. const [channelSwitcherRevealed, setChannelSwitcherRevealed] = useState(false) useEffect(() => { diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx index 7875deea064..b88bc1c102e 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx @@ -222,9 +222,14 @@ describe('SidebarSettingsHelpMenu', () => { expect(html).not.toContain('Milestones') }) - it('hides the Onboarding admin entry by default', () => { + it('renders the Onboarding menu item by default', () => { const html = renderToStaticMarkup() - expect(html).not.toContain('Onboarding') + expect(html).toContain('Onboarding') + }) + + it('renders Restart Orca by default', () => { + const html = renderToStaticMarkup() + expect(html).toContain('Restart Orca') }) it('renders Docs link', () => { diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index 9fee62dde2a..fc4975eb474 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -95,7 +95,6 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { const settingsShortcut = useShortcutKeyDetails('app.settings') const [menuOpen, setMenuOpen] = useState(false) const [feedbackOpen, setFeedbackOpen] = useState(false) - const [showAdminOptions, setShowAdminOptions] = useState(false) const [isRestartingOrca, setIsRestartingOrca] = useState(false) const lastShowOnboardingAtRef = React.useRef(0) const updateCheckModifiersRef = React.useRef(NO_UPDATE_CHECK_MODIFIERS) @@ -108,15 +107,6 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { const handleMenuOpenChange = (open: boolean): void => { setMenuOpen(open) updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS - if (!open) { - setShowAdminOptions(false) - } - } - - const revealAdminOptions = (altKey: boolean): void => { - // Why: onboarding replay and restart stay off the default Help menu; holding - // Option/Alt before opening is an intentional power-user affordance. - setShowAdminOptions(altKey) } const handleShowOnboarding = (): void => { @@ -221,8 +211,6 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { 'Help' )} className="text-muted-foreground" - onPointerDown={(event) => revealAdminOptions(event.altKey)} - onClick={(event) => revealAdminOptions(event.altKey)} > @@ -268,19 +256,17 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { /> ) : null} - {showAdminOptions ? ( - - - {translate( - 'auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c', - 'Onboarding' - )} - - ) : null} + + + {translate( + 'auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c', + 'Onboarding' + )} + - {showAdminOptions ? ( - <> - - - - {translate( - 'auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1', - 'Restart Orca' - )} - - - ) : null} + + + + {translate( + 'auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1', + 'Restart Orca' + )} + From c052ca10a34558b82276c6c84d213ebceb73463a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:32:00 -0700 Subject: [PATCH 018/101] fix(gitlab): expire project-ref negatives instead of caching them forever (#12390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitLab's project-ref cache stored `null` forever and returned any cached value straight from the map, so a repo probed before `origin` was configured — or before `glab auth login` ran for its self-hosted host — kept hosted-review provider detection stale until app restart. The negative-TTL work that shipped for Azure DevOps / Bitbucket / Gitea skipped it. Mirror `createRemoteRefProbeCache`'s semantics: negatives expire on the shared interval, positives stay, the SSH provider generation joins the cache signature so a reconnect re-asks, and a probe abandoned as stale can no longer publish over its successor. Transient git/SSH failures stay uncached. Expiring negatives would otherwise turn `glab auth status --hostname` into one spawn per repo per interval on the hosted-review poll, since a non-GitLab remote reaches it too, so remember the unauthenticated answer per host — not per repo — on the same clock. --- src/main/gitlab/gitlab-known-host-probe.ts | 63 +++++++++++ .../gitlab/gitlab-project-ref-resolution.ts | 77 +++++++++++-- src/main/gitlab/gl-utils.test.ts | 107 ++++++++++++++++++ src/main/gitlab/project-ref-inflight.ts | 2 +- 4 files changed, 236 insertions(+), 13 deletions(-) diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index cc4ecf5fad2..577baf9ad67 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -1,4 +1,5 @@ import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' +import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' import { glabExecFileAsync } from '../git/runner' import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser' @@ -8,8 +9,10 @@ export type LocalGitExecOptions = { } const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000 +const UNAUTHENTICATED_HOSTS_MAX_ENTRIES = 128 const knownHostsCacheByExecutionContext = new Map() const knownHostsInFlightByExecutionContext: CoalescedProbes = new Map() +const unauthenticatedHostExpiries = new Map() function knownHostsExecutionKey( connectionId?: string | null, @@ -26,6 +29,63 @@ function knownHostsExecutionKey( export function _resetKnownHostsCache(): void { knownHostsCacheByExecutionContext.clear() knownHostsInFlightByExecutionContext.clear() + unauthenticatedHostExpiries.clear() +} + +/** @internal - exposed for tests only */ +export function _resetGlabUnauthenticatedHosts(): void { + unauthenticatedHostExpiries.clear() +} + +function unauthenticatedHostKey( + host: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + return `${knownHostsExecutionKey(connectionId, localGitOptions)}\0${normalizeGitLabHost(host)}` +} + +/** + * Why: `glab auth status --hostname` is how a self-hosted instance that plain + * `glab auth status` did not list gets discovered, so a remote that is not + * GitLab at all runs it too. Project-ref negatives expire now, and without this + * that becomes one `glab` spawn per repo per interval on the hosted-review poll. + * The answer is per host, not per repo, and expires on the same clock so a + * login still lands within an interval. + */ +export function isGlabHostKnownUnauthenticated( + host: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): boolean { + const key = unauthenticatedHostKey(host, connectionId, localGitOptions) + const expiresAt = unauthenticatedHostExpiries.get(key) + if (expiresAt === undefined) { + return false + } + if (expiresAt > Date.now()) { + return true + } + unauthenticatedHostExpiries.delete(key) + return false +} + +export function rememberGlabHostUnauthenticated( + host: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): void { + unauthenticatedHostExpiries.set( + unauthenticatedHostKey(host, connectionId, localGitOptions), + Date.now() + NEGATIVE_ENTRY_TTL_MS + ) + while (unauthenticatedHostExpiries.size > UNAUTHENTICATED_HOSTS_MAX_ENTRIES) { + const oldestKey = unauthenticatedHostExpiries.keys().next().value + if (oldestKey === undefined) { + return + } + unauthenticatedHostExpiries.delete(oldestKey) + } } export function rememberGlabKnownHost( @@ -52,6 +112,9 @@ export function rememberGlabKnownHosts( } seen.add(normalizedHost) additions.push(normalizedHost) + unauthenticatedHostExpiries.delete( + unauthenticatedHostKey(normalizedHost, connectionId, localGitOptions) + ) } if (additions.length === 0) { return diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 4b33a4c438f..ee7203ce668 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -1,9 +1,14 @@ import { glabExecFileAsync } from '../git/runner' import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe' +import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' +import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import type { IssueSourcePreference } from '../../shared/types' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' import { + _resetGlabUnauthenticatedHosts, + isGlabHostKnownUnauthenticated, parseGlabAuthStatusHosts, + rememberGlabHostUnauthenticated, rememberGlabKnownHost, type LocalGitExecOptions } from './gitlab-known-host-probe' @@ -25,12 +30,16 @@ export { export type { LocalGitExecOptions } from './gitlab-known-host-probe' const PROJECT_REF_CACHE_MAX_ENTRIES = 512 -const projectRefCache = new Map() + +type CachedProjectRef = { value: ProjectRef | null; expiresAt: number } + +const projectRefCache = new Map() /** @internal - exposed for tests only */ export function _resetProjectRefCache(): void { projectRefCache.clear() clearProjectRefInFlight() + _resetGlabUnauthenticatedHosts() } /** @internal - exposed for tests only */ @@ -39,7 +48,14 @@ export function _getProjectRefCacheSize(): number { } function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void { - projectRefCache.set(cacheKey, value) + // Why: "not GitLab" only holds until someone configures `origin` or logs into + // `glab` — a repo probed before either kept hosted-review detection stale for + // the life of the process. Negatives expire the way every other forge's do; + // positives still stay (see `createRemoteRefProbeCache`). + projectRefCache.set(cacheKey, { + value, + expiresAt: value === null ? Date.now() + NEGATIVE_ENTRY_TTL_MS : Number.POSITIVE_INFINITY + }) while (projectRefCache.size > PROJECT_REF_CACHE_MAX_ENTRIES) { const oldestKey = projectRefCache.keys().next().value if (oldestKey === undefined) { @@ -56,19 +72,30 @@ export async function getProjectRefForRemote( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` + // Why: a reconnect replaces the host an answer came from under the same id, so + // the generation is part of the signature; `knownHosts` carries the glab auth + // state, so logging into a self-hosted instance re-asks rather than reusing a + // ref resolved while that host was unknown. + const runtimeKey = connectionId + ? `${connectionId}:${getSshGitProviderGeneration(connectionId)}` + : `local:${localGitOptions.wslDistro ?? 'host'}` const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}` - if (projectRefCache.has(cacheKey)) { - return projectRefCache.get(cacheKey)! + const cached = projectRefCache.get(cacheKey) + if (cached) { + if (cached.expiresAt > Date.now()) { + return cached.value + } + projectRefCache.delete(cacheKey) } - return runProjectRefProbeOnce(cacheKey, () => + return runProjectRefProbeOnce(cacheKey, (ownsKey) => resolveProjectRefForRemote( repoPath, remoteName, knownHosts, connectionId, cacheKey, + ownsKey, localGitOptions ) ) @@ -80,8 +107,17 @@ async function resolveProjectRefForRemote( knownHosts: readonly string[], connectionId: string | null | undefined, cacheKey: string, + ownsKey: () => boolean, localGitOptions: LocalGitExecOptions ): Promise { + // Why: a probe abandoned as stale still runs, and the repo state it read is + // older than whatever its successor already published. It may answer its own + // callers; it may not overwrite the cache. + const publish = (value: ProjectRef | null): void => { + if (ownsKey()) { + rememberProjectRefCacheEntry(cacheKey, value) + } + } try { const stdout = await readRemoteUrl( { @@ -96,7 +132,7 @@ async function resolveProjectRefForRemote( } const result = parseGitLabProjectRef(stdout, knownHosts) if (result) { - rememberProjectRefCacheEntry(cacheKey, result) + publish(result) return result } const remoteCandidate = parseRemoteProjectRefCandidate(stdout) @@ -110,17 +146,20 @@ async function resolveProjectRefForRemote( )) ) { rememberGlabKnownHost(remoteCandidate.host, connectionId, localGitOptions) - rememberProjectRefCacheEntry(cacheKey, remoteCandidate) + publish(remoteCandidate) return remoteCandidate } } catch (error) { // Why: a wedged or killed probe is not evidence the remote is not GitLab — - // caching it would misdetect the forge for the life of the process (P1-D). + // caching it would misdetect the forge until the negative expires (P1-D). + // SSH failures stay uncached outright rather than adopting the generic + // cache's stable-missing-remote exception: keeping a connected host's + // detection fresh is worth the extra probe. if (connectionId || isTransientGitProbeError(error)) { return null } } - rememberProjectRefCacheEntry(cacheKey, null) + publish(null) return null } @@ -229,12 +268,22 @@ async function isGlabConfiguredForRemoteHost( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + // Why: this probe is per host, but the project-ref miss that reaches it is per + // repo — without the memo, every non-GitLab repo re-spawns `glab` each time + // its negative expires. + if (isGlabHostKnownUnauthenticated(projectRef.host, connectionId, localGitOptions)) { + return false + } try { const result = await glabExecFileAsync( ['auth', 'status', '--hostname', projectRef.host], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) - return result !== undefined + if (result === undefined) { + rememberGlabHostUnauthenticated(projectRef.host, connectionId, localGitOptions) + return false + } + return true } catch (error) { const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown } const output = @@ -242,6 +291,10 @@ async function isGlabConfiguredForRemoteHost( .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) .join('\n') || String(error) const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost) - return hosts.includes(normalizeGitLabHost(projectRef.host)) + if (hosts.includes(normalizeGitLabHost(projectRef.host))) { + return true + } + rememberGlabHostUnauthenticated(projectRef.host, connectionId, localGitOptions) + return false } } diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 23e159671c6..e1937f3f2aa 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -28,16 +28,19 @@ import { import { rememberGlabKnownHost, rememberGlabKnownHosts } from './gitlab-known-host-probe' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' +import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' describe('gitlab project ref resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() + glabExecFileAsyncMock.mockReset() sshExecMock.mockReset() unregisterSshGitProvider('conn-1') _resetProjectRefCache() }) afterEach(() => { + vi.useRealTimers() unregisterSshGitProvider('conn-1') }) @@ -211,6 +214,110 @@ describe('gitlab project ref resolution', () => { path: 'remote/orca' }) }) + + it('does not cache a local probe killed on its deadline as a definitive miss', async () => { + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('git timed out.')) + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:fork/orca.git\n' }) + + await expect(getProjectRef('/repo')).resolves.toBeNull() + await expect(getProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'fork/orca' + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('re-probes a repo whose GitLab remote could have been added since the miss', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + gitExecFileAsyncMock.mockRejectedValueOnce(new Error("error: No such remote 'origin'")) + + await expect(getProjectRef('/repo')).resolves.toBeNull() + await expect(getProjectRef('/repo')).resolves.toBeNull() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + + // Nothing watches `.git/config`, and SSH/WSL repos have no file to watch, so + // a remote configured after the miss is only visible once the negative ages out. + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.com:fork/orca.git\n' }) + vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) + + await expect(getProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'fork/orca' + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('keeps a resolved project ref past the negative interval', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.com:fork/orca.git\n' }) + + await expect(getProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'fork/orca' + }) + vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS * 10) + await expect(getProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'fork/orca' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('re-resolves a self-hosted remote once glab auth knows its host', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.internal:team/orca.git\n' }) + glabExecFileAsyncMock.mockRejectedValue(new Error('not authenticated')) + + await expect(getProjectRefForRemote('/repo', 'origin', ['gitlab.com'])).resolves.toBeNull() + await expect( + getProjectRefForRemote('/repo', 'origin', ['gitlab.com', 'gitlab.internal']) + ).resolves.toEqual({ host: 'gitlab.internal', path: 'team/orca' }) + }) + + it('asks glab about an unauthenticated host once per interval, not once per repo', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@github.com:team/orca.git\n' }) + glabExecFileAsyncMock.mockRejectedValue(new Error('not authenticated')) + + // Expiring project-ref negatives must not turn the hosted-review poll into a + // `glab auth status` spawn per repo per interval — the answer is per host. + for (const repoPath of ['/repo-a', '/repo-b', '/repo-c']) { + await expect(getProjectRef(repoPath)).resolves.toBeNull() + } + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) + for (const repoPath of ['/repo-a', '/repo-b', '/repo-c']) { + await expect(getProjectRef(repoPath)).resolves.toBeNull() + } + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not serve a project ref resolved on a retired SSH connection', async () => { + sshExecMock + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:before/orca.git\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:after/orca.git\n', stderr: '' }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ + host: 'gitlab.com', + path: 'before/orca' + }) + + // A reconnect can swap the execution host under the same connection id. + unregisterSshGitProvider('conn-1') + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ + host: 'gitlab.com', + path: 'after/orca' + }) + expect(sshExecMock).toHaveBeenCalledTimes(2) + }) }) describe('resolveIssueSource', () => { diff --git a/src/main/gitlab/project-ref-inflight.ts b/src/main/gitlab/project-ref-inflight.ts index a9de9d75797..cea4173c48f 100644 --- a/src/main/gitlab/project-ref-inflight.ts +++ b/src/main/gitlab/project-ref-inflight.ts @@ -9,7 +9,7 @@ export function clearProjectRefInFlight(): void { export async function runProjectRefProbeOnce( cacheKey: string, - createProbe: () => Promise + createProbe: (ownsKey: () => boolean) => Promise ): Promise { // Why: joining only a probe that is still young keeps a wedged host's dead // promise from pinning every later retry for the process lifetime (P1-D). From 0db12feee80d1286c152b2cba33bf4eb5840e73f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 19:44:30 -0700 Subject: [PATCH 019/101] fix(runtime): deliver subscription close when retiring a remote transport (#12384) --- config/reliability-gates.jsonc | 120 +++++ src/main/ipc/runtime-environments.test.ts | 357 +++++++++++++ src/main/ipc/runtime-environments.ts | 40 +- ...rminal-parked-reveal-interactivity.spec.ts | 484 ++++++++++++++++++ 4 files changed, 1000 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index de0afcf8a90..802526681da 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1031,6 +1031,126 @@ ], "demotionRule": "Demote if any probe can run before lifecycle ownership, if more than one shared heartbeat timer is armed, if first-socket cleanup exceeds one interval, if later-socket cleanup exceeds two intervals, or if close/error cleanup retains listeners or timers." }, + { + "id": "runtime.streaming-subscription-close-delivery", + "title": "Retiring a runtime transport always tells the renderer its streams closed", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "runtime-subscription-ipc-contract", + "surfaces": [ + "paired remote server", + "runtime environment disconnect and re-pair", + "terminal.multiplex streaming", + "browser screencast streaming", + "parked remote terminal reveal" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Deterministic main-IPC contract tests cover disconnect-driven close delivery, exactly-once close, per-subscription teardown isolation against a failing socket close and a throwing liveness probe, containment of a throwing renderer send on the unguarded host-close path, and continued suppression of stale payloads from a retired transport. A headed paired-server journey (real Orca host plus a separate paired Orca desktop client) covers hidden-but-mounted reveal, cold-parked reveal, and cold-parked reveal across a disconnect/reconnect. Live Linux and Windows paired-server evidence and real sleep/wake transport loss remain uncollected.", + "motivatingLinks": [ + "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts", + "docs/reference/headless-linux-server.md" + ], + "invariant": "Every renderer-held runtime subscription receives exactly one terminal close event when its transport is retired, including when the retirement advanced the transport generation first, and a single failing teardown never abandons that environment's remaining subscriptions nor escapes into the transport that reported the close. Payload frames from a retired transport stay suppressed. A revealed remote terminal therefore reattaches over a live multiplex connection: its buffer restores, typed input reaches the host PTY, the echo paints without a tab flip, and the PTY converges on the revealed pane grid.", + "oracle": "The main IPC contract test subscribes terminal.multiplex through the real handler, disconnects the environment, and asserts the renderer received exactly one {type: close} subscription event. Two isolation tests subscribe a second stream to the same environment and make the first one fail -- in its socket close, and in the liveness probe inside notifyClosed -- then assert the disconnect does not throw, both transports closed, and every close the renderer could still receive was delivered. A third drives a host-initiated close through the transport callback, which is the one notifyClosed call site with no surrounding guard, with a renderer send that throws, and asserts it cannot escape into the WebSocket close handler. A fourth test asserts that after retirement a late response frame is not forwarded and a late transport close does not re-send. The paired-server journey runs three reveal scenarios against one real host and one real paired desktop client, and for each records buffer restore, host-side receipt of the typed marker through an out-of-band host sink file, live paint without a tab flip, and PTY-versus-pane grid convergence.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/runtime-environments.test.ts", + "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/main/ipc/runtime-environments.test.ts", + "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/runtime-environments.test.ts", + "assertions": [ + "tells the renderer when retiring the transport closes its streaming subscription (the load-bearing repro; red with the fix reverted)", + "retires an environment's remaining subscriptions when one teardown throws (load-bearing; red without per-subscription isolation)", + "contains a throwing renderer send on a host-initiated close (load-bearing; red without the guarded send, and the only coverage of the unguarded notifyClosed call site)", + "retires remaining subscriptions when a liveness probe inside notifyClosed throws (load-bearing; keeps the isolation structural rather than comment-asserted)", + "suppresses stale payloads from a retired transport but never re-sends its close (forward guard on the retained generation gate, not a repro)" + ] + }, + { + "file": "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts", + "assertions": [ + "a revealed hidden-but-mounted remote terminal restores, accepts input, and paints live", + "a revealed cold-parked remote terminal restores, accepts input, and paints live", + "a cold-parked remote terminal revealed after a runtime disconnect and reconnect restores, accepts input, and paints live", + "the host PTY grid converges on the revealed pane grid in every scenario" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/runtime-environments.test.ts", + "result": "passed", + "durationSeconds": 0.81, + "summary": "53 runtime environment IPC tests passed, including the new close-delivery, both teardown-isolation, transport-path containment, and stale-payload-suppression contracts." + }, + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 15.5, + "summary": "All three reveal scenarios restored their buffer, delivered typed input to the host PTY, painted live without a tab flip, and converged the PTY on the 135x60 pane grid. Before the fix the reconnect-parked scenario stayed blank at recoveryState connecting with the PTY stranded at the host 128x60 grid." + }, + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 15.5, + "summary": "Repeat run on a clean uninstrumented build; all three scenarios green with identical grids." + }, + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 14.7, + "summary": "Third consecutive pass after the review follow-ups (env save/restore and the hidden-mounted stayed-mounted assertion)." + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "focused runtime environment IPC tests plus one headed paired-server reveal journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Deterministic IPC coverage plus the three recorded paired-server journey runs, which passed consecutively on macOS at 14.7-15.5s; the spec also passed once on a shared CI runner. Longer soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "On main the paired-server journey reproduced a blank, non-interactive pane after revealing a cold-parked remote terminal across a reconnect: recoveryState stayed connecting, the xterm buffer stayed empty, typed input never reached the host sink, a tab flip did not recover it, and the PTY stayed at the host 128x60 grid. Renderer instrumentation showed the multiplexer reusing a dead subscription (ensureConnected reuse-ready) and never receiving a subscribed event. Reverting the main-side fix also turns the close-delivery IPC test red; with the fix the multiplexer observes handleClose, reconnects fresh, and every scenario passes." + }, + "performanceBudget": { + "required": true, + "evidence": "The fix adds one boolean latch and at most one extra IPC send per retired subscription, on a path that already tears the subscription down. It introduces no timer, poll, retry, subprocess, or per-frame work, and the close is deduplicated so a transport-driven close after an environment-wide retirement sends nothing." + }, + "promotionCriteria": [ + "Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.", + "Collect live Linux and Windows paired-server reveal evidence.", + "Add real sleep/wake and network-loss transport drops alongside the explicit disconnect trigger." + ], + "knownGaps": [ + "Live paired-server validation currently covers macOS host and client only.", + "The transport drop is an explicit runtime disconnect; real sleep/wake and network partitions are not yet exercised by this gate.", + "Browser screencast subscribers share the fixed contract but have no dedicated reveal journey; the web and mobile clients run parallel transports and are unaffected." + ], + "demotionRule": "Demote if a retired runtime transport can leave a renderer subscription without a close event, if a close is delivered more than once, if one failing teardown strands its sibling subscriptions, if payload frames from a retired transport reach the renderer, or if a revealed remote terminal can stay blank or reject input after a reconnect." + }, { "id": "editor.live-log-append-stability", "title": "Long live session logs retain their Monaco viewport while appending", diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index d0101ee3968..0e3739d6ccb 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -1689,6 +1689,363 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) }) + it('tells the renderer when retiring the transport closes its streaming subscription', async () => { + // Why: disconnect advances the transport generation before closing sockets. + // Gating the terminal 'close' on that generation stranded the renderer with a + // handle it believed was open, so every later subscribe wrote into a socket + // main no longer owned — blank, wedged remote terminals after a reconnect. + registerRuntimeEnvironmentHandlers(store as never) + let transportCallbacks: { + onResponse: (response: Record) => void + onClose: () => void + } | null = null + const close = vi.fn(() => { + transportCallbacks?.onClose() + }) + subscribeRemoteRuntimeRequestMock.mockImplementation( + async ( + _environment: unknown, + _method: string, + _params: unknown, + _timeoutMs: number, + callbacks: NonNullable + ) => { + transportCallbacks = callbacks + return { requestId: 'multiplex-1', close, sendBinary: vi.fn() } + } + ) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const senderSend = vi.fn() + const subscribe = handler< + { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const subscribed = await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + }, + { + selector: added.environment.id, + method: 'terminal.multiplex', + params: {}, + subscriptionId: 'multiplex-sub' + } + ) + + const disconnect = handler<{ selector: string }, { disconnected: { id: string } }>( + 'runtimeEnvironments:disconnect' + ) + disconnect(null, { selector: added.environment.id }) + + const closeEvents = senderSend.mock.calls.filter( + (call) => + call[0] === 'runtimeEnvironments:subscriptionEvent' && + (call[1] as { type?: string }).type === 'close' + ) + expect(closeEvents).toEqual([ + [ + 'runtimeEnvironments:subscriptionEvent', + { subscriptionId: subscribed.subscriptionId, type: 'close' } + ] + ]) + }) + + it("retires an environment's remaining subscriptions when one teardown throws", async () => { + // Why: the sweep exists to retire dead handles, so a single failing teardown + // must not strand the very sockets it was called to close. + registerRuntimeEnvironmentHandlers(store as never) + const closeCalls: string[] = [] + let streamCount = 0 + subscribeRemoteRuntimeRequestMock.mockImplementation(async () => { + streamCount += 1 + const requestId = `stream-${streamCount}` + return { + requestId, + close: () => { + closeCalls.push(requestId) + if (requestId === 'stream-1') { + throw new Error('socket teardown exploded') + } + }, + sendBinary: vi.fn() + } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const senderSend = vi.fn() + const subscribe = handler< + { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const sender = { + sender: { + id: 1, + isDestroyed: () => false, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + } + await subscribe(sender, { + selector: added.environment.id, + method: 'terminal.multiplex', + params: {}, + subscriptionId: 'doomed-sub' + }) + await subscribe(sender, { + selector: added.environment.id, + method: 'browser.screencast', + params: {}, + subscriptionId: 'sibling-sub' + }) + + const disconnect = handler<{ selector: string }, { disconnected: { id: string } }>( + 'runtimeEnvironments:disconnect' + ) + expect(() => disconnect(null, { selector: added.environment.id })).not.toThrow() + + expect(closeCalls).toEqual(['stream-1', 'stream-2']) + expect( + senderSend.mock.calls + .filter((call) => (call[1] as { type?: string }).type === 'close') + .map((call) => (call[1] as { subscriptionId: string }).subscriptionId) + ).toEqual(['doomed-sub', 'sibling-sub']) + + // Both entries are gone, so a later unsubscribe finds nothing to release. + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + expect(await unsubscribe({ sender: { id: 1 } }, { subscriptionId: 'sibling-sub' })).toEqual({ + unsubscribed: false + }) + }) + + it('contains a throwing renderer send on a host-initiated close', async () => { + // Why: this is the notifyClosed call site with no surrounding guard. A host + // close arriving on a disposed render frame would otherwise throw out through + // the transport's onClose and into the WebSocket close handler. + registerRuntimeEnvironmentHandlers(store as never) + let transportCallbacks: { + onResponse: (response: Record) => void + onClose: () => void + } | null = null + subscribeRemoteRuntimeRequestMock.mockImplementation( + async ( + _environment: unknown, + _method: string, + _params: unknown, + _timeoutMs: number, + callbacks: NonNullable + ) => { + transportCallbacks = callbacks + return { requestId: 'host-closed-stream', close: vi.fn(), sendBinary: vi.fn() } + } + ) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const senderSend = vi.fn((_channel: string, payload: { type: string }) => { + if (payload.type === 'close') { + throw new Error('Render frame was disposed') + } + }) + const subscribe = handler< + { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + }, + { + selector: added.environment.id, + method: 'terminal.multiplex', + params: {}, + subscriptionId: 'host-closed-sub' + } + ) + + // The host closes the stream on its own; nothing wraps this call site. + expect(() => transportCallbacks!.onClose()).not.toThrow() + expect( + senderSend.mock.calls.filter((call) => (call[1] as { type?: string }).type === 'close') + ).toHaveLength(1) + + // The entry is still released, so a later unsubscribe finds nothing. + const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( + 'runtimeEnvironments:unsubscribe' + ) + expect(await unsubscribe({ sender: { id: 1 } }, { subscriptionId: 'host-closed-sub' })).toEqual( + { + unsubscribed: false + } + ) + }) + + it('retires remaining subscriptions when a liveness probe inside notifyClosed throws', async () => { + // Why: notifyClosed guards its own send, but the sweep must not depend on + // everything else inside it staying throw-free -- that is how the abandoned + // -siblings defect comes back the next time a line is added there. + registerRuntimeEnvironmentHandlers(store as never) + const closedStreams: string[] = [] + let streamCount = 0 + subscribeRemoteRuntimeRequestMock.mockImplementation(async () => { + streamCount += 1 + const requestId = `stream-${streamCount}` + return { + requestId, + close: () => closedStreams.push(requestId), + sendBinary: vi.fn() + } + }) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const deliveredCloses: string[] = [] + const senderSend = vi.fn( + (_channel: string, payload: { subscriptionId: string; type: string }) => { + if (payload.type === 'close') { + deliveredCloses.push(payload.subscriptionId) + } + } + ) + let probeShouldThrow = false + const subscribe = handler< + { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + const sender = { + sender: { + id: 1, + isDestroyed: () => { + if (probeShouldThrow) { + throw new Error('WebContents liveness probe exploded') + } + return false + }, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + } + await subscribe(sender, { + selector: added.environment.id, + method: 'terminal.multiplex', + params: {}, + subscriptionId: 'probe-throws-sub' + }) + await subscribe(sender, { + selector: added.environment.id, + method: 'browser.screencast', + params: {}, + subscriptionId: 'surviving-sub' + }) + // Why: arm only after subscribe, whose own isDestroyed checks must succeed. + probeShouldThrow = true + + const disconnect = handler<{ selector: string }, { disconnected: { id: string } }>( + 'runtimeEnvironments:disconnect' + ) + expect(() => disconnect(null, { selector: added.environment.id })).not.toThrow() + + // Both transports still closed even though every notifyClosed probe threw. + expect(closedStreams).toEqual(['stream-1', 'stream-2']) + expect(deliveredCloses).toEqual([]) + }) + + it('suppresses stale payloads from a retired transport but never re-sends its close', async () => { + registerRuntimeEnvironmentHandlers(store as never) + let transportCallbacks: { + onResponse: (response: Record) => void + onClose: () => void + } | null = null + subscribeRemoteRuntimeRequestMock.mockImplementation( + async ( + _environment: unknown, + _method: string, + _params: unknown, + _timeoutMs: number, + callbacks: NonNullable + ) => { + transportCallbacks = callbacks + return { requestId: 'multiplex-2', close: vi.fn(), sendBinary: vi.fn() } + } + ) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) + + const senderSend = vi.fn() + const subscribe = handler< + { selector: string; method: string; params?: unknown; subscriptionId?: string }, + { subscriptionId: string; requestId: string } + >('runtimeEnvironments:subscribe') + await subscribe( + { + sender: { + id: 1, + isDestroyed: () => false, + send: senderSend, + once: vi.fn(), + removeListener: vi.fn() + } + }, + { + selector: added.environment.id, + method: 'terminal.multiplex', + params: {}, + subscriptionId: 'multiplex-stale' + } + ) + + invalidateRuntimeEnvironmentTransport(added.environment.id) + senderSend.mockClear() + // A late frame from the retired socket must not reach the renderer... + transportCallbacks!.onResponse({ + id: 'r1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-a' } + }) + // ...and its late close must not re-fire after the retirement already sent one. + transportCallbacks!.onClose() + expect(senderSend).not.toHaveBeenCalled() + }) + it('rejects cross-window streaming subscription control', async () => { registerRuntimeEnvironmentHandlers(store as never) const close = vi.fn() diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 6bf71edd634..e31f8569dec 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -25,6 +25,7 @@ type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { environmentId: string ownerWebContentsId: number removeDestroyedListener: () => void + notifyClosed: () => void } const remoteRuntimeSubscriptions = new Map() const getUserDataPath = (): string => app.getPath('userData') @@ -36,7 +37,22 @@ function closeSubscriptionsForEnvironment(environmentId: string): void { continue } remoteRuntimeSubscriptions.delete(subscriptionId) - subscription.close() + // Why: one failing teardown must not abandon this environment's other + // sockets -- that strands exactly the dead handles this sweep exists to + // retire. Guard the two steps independently so neither can skip the other, + // and so the isolation stays structural rather than resting on a claim that + // nothing inside notifyClosed will ever throw. + try { + subscription.close() + } catch (error) { + console.warn('[runtime-environments] subscription close failed during retirement:', error) + } + try { + // Why: a shared-control logical close never calls back, so notify directly. + subscription.notifyClosed() + } catch (error) { + console.warn('[runtime-environments] subscription close notice failed:', error) + } } } export function invalidateRuntimeEnvironmentTransport(environmentId: string): void { @@ -120,6 +136,21 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { removeDestroyedListener() subscription?.close() } + // Why: the renderer treats close as terminal and drops its handle, so send it once. + // Latch before sending so a re-entrant call cannot duplicate it, and never + // throw: a dying renderer must not abort its siblings' retirement. + let closeNotified = false + const notifyClosed = (): void => { + if (closeNotified || sender.isDestroyed()) { + return + } + closeNotified = true + try { + sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, type: 'close' }) + } catch { + // The renderer is gone; there is no one left to tell. + } + } sender.once('destroyed', closeSubscription) destroyedListenerAttached = true try { @@ -131,6 +162,12 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { args.timeoutMs, { onEvent: (payload) => { + if (payload.type === 'close') { + // Why: retirement advances the generation before closing, so gating + // close on it stranded the renderer with a dead subscription. + notifyClosed() + return + } if (transportIsCurrent() && !sender.isDestroyed()) { sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, @@ -172,6 +209,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { environmentId: environment.id, ownerWebContentsId, removeDestroyedListener, + notifyClosed, sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, close: () => { removeDestroyedListener() diff --git a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts new file mode 100644 index 00000000000..acabed0650e --- /dev/null +++ b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts @@ -0,0 +1,484 @@ +/** + * Paired remote server: a revealed remote terminal must stay interactive + * without a tab flip. + * + * Topology: headed Orca desktop host (remote server) + a separate paired Orca + * desktop client — the "connect to Windows 2, open an old workspace" shape. + * + * Oracle (the reported symptom verbatim): type into the revealed pane and see + * the echo paint live. "Paints only after switching to another terminal and + * back" is the failure, so each scenario records both. + * + * The decoy tabs are load-bearing, not scenery. Pre-fix, handleClose never runs, + * so closeIfIdle is the only remaining release path and it needs zero streams — + * a sibling stream is what keeps the wedged multiplexer alive and the pre-fix + * state red. Each scenario asserts its flip decoy is still mounted at the reveal + * so that invariant cannot quietly lapse and turn this green. + * + * Run: + * pnpm exec playwright test \ + * tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { + HOST_TERMINAL_SURFACE_SEPARATOR, + toWebTerminalSurfaceTabId +} from '../../src/shared/terminal-surface-id' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { focusActiveTerminalInput } from './helpers/terminal' +import { waitForTabParked } from './helpers/terminal-hidden-parking' + +const PARK_DELAY_MS = 2_000 +const LIVE_PAINT_BUDGET_MS = 12_000 +const REVEAL_BUDGET_MS = 20_000 +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-parked-reveal-')) +const fixturePath = path.join(scratch, 'parked-reveal-terminal.mjs') +writeFileSync( + fixturePath, + [ + "import { appendFileSync } from 'node:fs'", + 'const sink = process.argv[2]', + 'const size = () => `${process.stdout.columns}x${process.stdout.rows}`', + 'const record = (line) => appendFileSync(sink, `${line}\\n`)', + 'record(`READY:${size()}`)', + 'process.stdout.write(`READY:${size()}\\r\\n`)', + "process.stdout.on('resize', () => {", + ' record(`SIZE:${size()}`)', + ' process.stdout.write(`SIZE:${size()}\\r\\n`)', + '})', + "process.stdin.setEncoding('utf8')", + "let pending = ''", + "process.stdin.on('data', (data) => {", + ' pending += data', + ' const lines = pending.split(/\\r\\n|\\r|\\n/)', + " pending = lines.pop() ?? ''", + ' for (const line of lines) {', + ' record(`LINE:${line}`)', + ' process.stdout.write(`LINE:${line}\\r\\n`)', + ' }', + '})', + 'process.stdin.resume()' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function fixtureCommand(sinkPath: string): string { + const command = [process.execPath, fixturePath, sinkPath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map(shellQuote).join(' ') +} + +function readSink(sinkPath: string): string { + try { + return readFileSync(sinkPath, 'utf8') + } catch { + return '' + } +} + +async function callEnvironment( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ environmentId, method, params }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method, + params + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { environmentId, method, params } + ) as Promise +} + +type HostTerminal = { + hostTabId: string + sinkPath: string + terminal: string + webTabId: string +} + +async function createHostTerminal( + page: Page, + environmentId: string, + worktreeId: string +): Promise { + const sinkPath = path.join(scratch, `sink-${randomUUID()}.log`) + const result = await callEnvironment<{ tab: { id: string; terminal: string | null } }>( + page, + environmentId, + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + command: fixtureCommand(sinkPath), + activate: false, + select: false, + navigation: 'caller' + } + ) + if (!result.tab.terminal) { + throw new Error('host session terminal was not created') + } + // Why: the host answers with a `tabId::leafId` surface id; client tabs mirror the parent tab. + const hostTabId = result.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0] + return { + hostTabId, + sinkPath, + terminal: result.tab.terminal, + webTabId: toWebTerminalSurfaceTabId(hostTabId) + } +} + +async function openClientTab(page: Page, worktreeId: string, webTabId: string): Promise { + await expect + .poll( + () => + page.evaluate( + (id) => (window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => tab.id), + worktreeId + ), + { timeout: 60_000, message: `client never mirrored host tab ${webTabId}` } + ) + .toContain(webTabId) + await page.evaluate( + ({ webTabId, worktreeId }) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(worktreeId) + state?.setActiveTab(webTabId) + state?.setActiveTabType('terminal') + }, + { webTabId, worktreeId } + ) + await expect + .poll(() => page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId), { + timeout: 60_000, + message: `client pane for ${webTabId} did not mount` + }) + .toBe(true) +} + +async function readActivePaneGrid( + page: Page, + webTabId: string +): Promise<{ cols: number; rows: number } | null> { + return page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane ? { cols: pane.terminal.cols, rows: pane.terminal.rows } : null + }, webTabId) +} + +/** Reads the target tab's own buffer. `getTerminalContent` resolves whatever + * tab the store thinks is active, which hides per-tab reveal failures. */ +async function readPaneContent(page: Page, webTabId: string): Promise { + return page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.() ?? '' + }, webTabId) +} + +async function readPaneDiagnostics( + page: Page, + worktreeId: string, + webTabId: string +): Promise { + return page.evaluate( + ({ webTabId, worktreeId }) => { + const manager = window.__paneManagers?.get(webTabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree[worktreeId] ?? []).find((entry) => entry.id === webTabId) + return { + mounted: Boolean(manager), + ptyId: pane?.container?.dataset?.ptyId ?? null, + recoveryState: pane?.container?.dataset?.ptyRecoveryState ?? null, + cols: pane?.terminal?.cols ?? null, + rows: pane?.terminal?.rows ?? null, + bufferLength: pane?.serializeAddon?.serialize?.()?.length ?? null, + paneLeafIds: manager?.getPanes?.().map((entry) => entry.leafId ?? null) ?? null, + storeTabPtyId: tab?.ptyId ?? null, + storeTabLayout: tab?.paneLayout ? JSON.stringify(tab.paneLayout) : null, + storePtyIdsByTab: state?.ptyIdsByTabId?.[webTabId] ?? null + } + }, + { webTabId, worktreeId } + ) +} + +async function waitForPaneMarker( + page: Page, + webTabId: string, + marker: string, + budgetMs: number +): Promise { + const deadline = Date.now() + budgetMs + while (Date.now() < deadline) { + if ((await readPaneContent(page, webTabId)).includes(marker)) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + return false +} + +function readPtyGridFromContent(content: string): { cols: number; rows: number } | null { + const sizes = [...content.matchAll(/(?:READY|SIZE):(\d+)x(\d+)/g)] + const last = sizes.at(-1) + return last ? { cols: Number(last[1]), rows: Number(last[2]) } : null +} + +type ScenarioResult = { + name: string + restoredBuffer: boolean + hostReceivedInput: boolean + paintedLive: boolean + paintedAfterFlip: boolean + paneGrid: { cols: number; rows: number } | null + ptyGrid: { cols: number; rows: number } | null + diagnostics: unknown +} + +/** Types a unique marker into the revealed pane and records whether the host + * received it, whether it painted live, and (if not) whether the reported + * tab-flip workaround reveals it. */ +async function probeInteractivity( + page: Page, + worktreeId: string, + target: HostTerminal, + flipTo: HostTerminal, + name: string +): Promise { + const token = `probe-${name}` + // Why: a human types once the pane looks restored; typing earlier would race the reattach. + const restoredBuffer = await waitForPaneMarker(page, target.webTabId, 'READY:', REVEAL_BUDGET_MS) + await focusActiveTerminalInput(page) + await page.keyboard.type(token) + await page.keyboard.press('Enter') + const paintedLive = await waitForPaneMarker( + page, + target.webTabId, + `LINE:${token}`, + LIVE_PAINT_BUDGET_MS + ) + const paneGrid = await readActivePaneGrid(page, target.webTabId) + const diagnostics = await readPaneDiagnostics(page, worktreeId, target.webTabId) + let paintedAfterFlip = paintedLive + if (!paintedLive) { + await openClientTab(page, worktreeId, flipTo.webTabId) + await openClientTab(page, worktreeId, target.webTabId) + paintedAfterFlip = await waitForPaneMarker( + page, + target.webTabId, + `LINE:${token}`, + LIVE_PAINT_BUDGET_MS + ) + } + const sink = readSink(target.sinkPath) + return { + name, + restoredBuffer, + hostReceivedInput: sink.includes(`LINE:${token}`), + paintedLive, + paintedAfterFlip, + paneGrid, + ptyGrid: readPtyGridFromContent(sink), + diagnostics + } +} + +/** The wedged-multiplexer repro needs a sibling stream alive at reveal time + * (see the header), and S1 additionally needs its target never to have parked. */ +async function expectStillMounted(page: Page, webTabId: string, label: string): Promise { + expect( + await page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId), + `${label} parked before the reveal it is supposed to survive` + ).toBe(true) +} + +/** Logged unconditionally: on failure this line is the whole diagnosis. */ +function logResult(result: ScenarioResult): ScenarioResult { + console.log(`[paired-reveal] ${JSON.stringify(result)}`) + return result +} + +async function seedScenario( + client: PairedElectronClient, + worktreeId: string +): Promise<{ target: HostTerminal; decoys: HostTerminal[] }> { + const target = await createHostTerminal(client.page, client.environmentId, worktreeId) + const decoys = [ + await createHostTerminal(client.page, client.environmentId, worktreeId), + await createHostTerminal(client.page, client.environmentId, worktreeId) + ] + await openClientTab(client.page, worktreeId, target.webTabId) + await expect + .poll(() => readPaneContent(client.page, target.webTabId), { + timeout: 60_000, + message: 'target terminal never painted its READY marker' + }) + .toContain('READY:') + return { target, decoys } +} + +test('paired client keeps revealed remote terminals interactive', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(600_000) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + // Why: the paired client inherits this from the launching process; a reused + // Playwright worker would otherwise leak the shortened delay into later specs. + const previousParkDelay = process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS) + const client = await launchPairedElectronClient(offer, testInfo, 'parked-reveal') + const createdTerminals: string[] = [] + const results: ScenarioResult[] = [] + try { + const worktreeId = await orcaPage.evaluate(() => { + const id = window.__store?.getState().activeWorktreeId + if (!id) { + throw new Error('headed host has no active worktree') + } + return id + }) + await expect + .poll( + () => + client.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) ?? false, + worktreeId + ), + { timeout: 60_000, message: 'paired client never saw the host worktree' } + ) + .toBe(true) + await client.page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(id) + }, worktreeId) + + // S1 — hidden tab that stays mounted, then revealed. + { + const { target, decoys } = await seedScenario(client, worktreeId) + createdTerminals.push(target.terminal, ...decoys.map((decoy) => decoy.terminal)) + await openClientTab(client.page, worktreeId, decoys[0].webTabId) + // Pins the scenario label: this reveal must not have gone through a park. + await expectStillMounted(client.page, target.webTabId, 'hidden-mounted target') + await openClientTab(client.page, worktreeId, target.webTabId) + results.push( + logResult( + await probeInteractivity(client.page, worktreeId, target, decoys[1], 'hidden-mounted') + ) + ) + } + + // S2 — cold-parked tab (renderer unmounted), then revealed. + { + const { target, decoys } = await seedScenario(client, worktreeId) + createdTerminals.push(target.terminal, ...decoys.map((decoy) => decoy.terminal)) + await openClientTab(client.page, worktreeId, decoys[0].webTabId) + await openClientTab(client.page, worktreeId, decoys[1].webTabId) + await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS }) + await expectStillMounted(client.page, decoys[1].webTabId, 'cold-parked flip decoy') + await openClientTab(client.page, worktreeId, target.webTabId) + results.push( + logResult( + await probeInteractivity(client.page, worktreeId, target, decoys[1], 'cold-parked') + ) + ) + } + + // S3 — cold-parked tab whose runtime connection dropped and came back + // while parked (the "returned after a while" report). + { + const { target, decoys } = await seedScenario(client, worktreeId) + createdTerminals.push(target.terminal, ...decoys.map((decoy) => decoy.terminal)) + await openClientTab(client.page, worktreeId, decoys[0].webTabId) + await openClientTab(client.page, worktreeId, decoys[1].webTabId) + await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS }) + await client.page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.disconnect({ selector }) + }, client.environmentId) + await expect + .poll( + async () => + client.page.evaluate(async (selector) => { + const response = await window.api.runtimeEnvironments.connect({ selector }) + return response.ok + }, client.environmentId), + { timeout: 60_000, message: 'paired client never reconnected to the host runtime' } + ) + .toBe(true) + await expectStillMounted(client.page, decoys[1].webTabId, 'reconnect-parked flip decoy') + await openClientTab(client.page, worktreeId, target.webTabId) + results.push( + logResult( + await probeInteractivity(client.page, worktreeId, target, decoys[1], 'reconnect-parked') + ) + ) + } + + for (const result of results) { + expect( + { + scenario: result.name, + restoredBuffer: result.restoredBuffer, + hostReceivedInput: result.hostReceivedInput, + paintedLive: result.paintedLive + }, + `${result.name}: revealed pane was not interactive (painted after tab flip: ${result.paintedAfterFlip})` + ).toEqual({ + scenario: result.name, + restoredBuffer: true, + hostReceivedInput: true, + paintedLive: true + }) + expect( + { scenario: result.name, ptyGrid: result.ptyGrid }, + `${result.name}: host PTY geometry never converged on the revealed pane grid` + ).toEqual({ scenario: result.name, ptyGrid: result.paneGrid }) + } + } finally { + if (previousParkDelay === undefined) { + delete process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + } else { + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = previousParkDelay + } + for (const terminal of createdTerminals) { + await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', { + terminal + }).catch(() => undefined) + } + await client.dispose() + } +}) From 49dc113a0f3af364972111e5e9cb280bdf40d602 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:00:28 -0700 Subject: [PATCH 020/101] Fix terminal corruption after restored snapshot replay (#12363) * fix(terminal): preserve restored snapshot fidelity * test(terminal): align legacy history handoff snapshot expectation * fix(terminal): keep legacy snapshot panes mounted * fix(terminal): refresh snapshot capability after startup * fix(terminal): refresh snapshot capability in degraded startup * fix(terminal): await snapshot provider authority --- ...erm__addon-serialize@0.15.0-beta.287.patch | 109 +++++++++- pnpm-lock.yaml | 6 +- ...n-foreground-confirmation-protocol.test.ts | 3 +- .../daemon/daemon-protocol-version.test.ts | 6 +- src/main/daemon/daemon-protocol-version.ts | 7 +- src/main/daemon/daemon-pty-adapter.test.ts | 36 ++-- src/main/daemon/daemon-pty-adapter.ts | 6 +- .../daemon-pty-router-history-handoff.test.ts | 4 +- src/main/daemon/daemon-pty-router.test.ts | 50 +++-- .../headless-emulator-fidelity.fuzz.test.ts | 31 +-- .../terminal-snapshot-color-parity.test.ts | 202 ++++++++++++++++++ src/main/ipc/pty.test.ts | 58 +++++ src/main/ipc/pty.ts | 18 +- src/renderer/src/App.tsx | 7 +- src/renderer/src/app-startup-routing.test.ts | 36 +++- .../hidden-reveal-reconciliation.fuzz.test.ts | 16 +- .../terminal-hidden-view-parking.test.ts | 2 +- ...terminal-hidden-worktree-retention.test.ts | 28 ++- .../terminal-hidden-worktree-retention.ts | 13 +- .../terminal-parked-pane-candidates.test.ts | 83 +++++++ .../terminal-parked-tab-watchers.test.ts | 113 +++------- .../terminal-parked-tab-watchers.ts | 13 +- ...minal-provider-snapshot-capability.test.ts | 13 ++ .../terminal-provider-snapshot-capability.ts | 14 ++ .../local-build-compatibility-contract.json | 4 +- .../local-build-compatibility-contract.ts | 4 +- src/shared/terminal-restore-parity-fixture.ts | 154 ++++++------- 27 files changed, 764 insertions(+), 272 deletions(-) create mode 100644 src/main/daemon/terminal-snapshot-color-parity.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts diff --git a/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch b/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch index eab0cd58452..caa96484d89 100644 --- a/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch +++ b/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch @@ -1,28 +1,64 @@ diff --git a/lib/addon-serialize.js b/lib/addon-serialize.js -index d669293d06d801ef472d5c2454ce86876e5c321f..96bb66cdc05289b6a7ce5ed64fc833624c885c57 100644 +index d669293d06d801ef472d5c2454ce86876e5c321f..18f72336a60d498f685e2cc32b7a09dbe16958e5 100644 --- a/lib/addon-serialize.js +++ b/lib/addon-serialize.js @@ -1,2 +1,2 @@ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&a?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",r+=`[${e.length-this._nullCellCount}C`,r+=`[${this._nullCellCount}X`,r+=`[${e.length-this._nullCellCount}D`,r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),t.isBold()!==e.isBold()&&r.push(t.isBold()?1:22),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isDim()!==e.isDim()&&r.push(t.isDim()?2:22),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,this._cursorStyle);if(i?!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=t.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); -+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&a?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",r+=`[${e.length-this._nullCellCount}C`,r+=`[${this._nullCellCount}X`,r+=`[${e.length-this._nullCellCount}D`,r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!t.isBold()||d&&!t.isDim();c&&r.push(22),t.isBold()&&(b||c)&&r.push(1),t.isDim()&&(d||c)&&r.push(2)}})(t.isBold()!==e.isBold(),t.isDim()!==e.isDim()),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,this._cursorStyle);if(i?!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=t.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"
":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); ++!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&(a?this._nullCellCount<=1:this._nullCellCount<=0))&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",e.length-this._nullCellCount>0&&(r+=`[${e.length-this._nullCellCount}C`),r+=`[${this._nullCellCount}X`,e.length-this._nullCellCount>0&&(r+=`[${e.length-this._nullCellCount}D`),r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!t.isBold()||d&&!t.isDim();c&&r.push(22),t.isBold()&&(b||c)&&r.push(1),t.isDim()&&(d||c)&&r.push(2)}})(t.isBold()!==e.isBold(),t.isDim()!==e.isDim()),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),c=i&&t.isInverse()?this._buffer.getLine(r+1):void 0,p=c?.getCell(0,this._nextRowFirstChar),f=s===this._terminal.cols-1&&c?.isWrapped&&(p?.getWidth()??0)>1&&!!p&&h(t,p),a=i&&!!t.isInverse()&&!f,d=a&&(!!t.isUnderline()||!!t.isStrikethrough()||!!t.isOverline()),n=this._diffStyle(t,this._cursorStyle);if(i?a?n.length>0:!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i&&!a?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),(a?(d&&(this._currentRow+="\x1B[24;29;55m"),this._currentRow+=" ".repeat(t.getWidth()),d&&(this._currentRow+=`\x1B[0m\x1B[${this._diffStyle(t,this._defaultCell).join(";")}m`)):this._currentRow+=t.getChars()),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"
":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); //# sourceMappingURL=addon-serialize.js.map \ No newline at end of file diff --git a/lib/addon-serialize.mjs b/lib/addon-serialize.mjs -index a5c4c4dd05b7efcc23d0e45661ffd4e42da80f21..475e45b3ed228027531deea5530bd03859f4a4e0 100644 +index a5c4c4dd05b7efcc23d0e45661ffd4e42da80f21..bb46224439629de3845c0b56ab9e4cd397dfe098 100644 --- a/lib/addon-serialize.mjs +++ b/lib/addon-serialize.mjs -@@ -15,5 +15,5 @@ +@@ -14,6 +14,6 @@ + * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - var m=0,b=0,_=0,p=0;var g;(t=>{function a(r,l,s,i){return i!==void 0?`#${w(r)}${w(l)}${w(s)}${w(i)}`:`#${w(r)}${w(l)}${w(s)}`}t.toCss=a;function n(r,l,s,i=255){return(r<<24|l<<16|s<<8|i)>>>0}t.toRgba=n;function e(r,l,s,i){return{css:t.toCss(r,l,s,i),rgba:t.toRgba(r,l,s,i)}}t.toColor=e})(g||={});var N;(i=>{function a(o,u){if(p=(u.rgba&255)/255,p===1)return{css:u.css,rgba:u.rgba};let f=u.rgba>>24&255,C=u.rgba>>16&255,c=u.rgba>>8&255,h=o.rgba>>24&255,d=o.rgba>>16&255,I=o.rgba>>8&255;m=h+Math.round((f-h)*p),b=d+Math.round((C-d)*p),_=I+Math.round((c-I)*p);let L=g.toCss(m,b,_),E=g.toRgba(m,b,_);return{css:L,rgba:E}}i.blend=a;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,u,f){let C=B.ensureContrastRatio(o.rgba,u.rgba,f);if(C)return g.toColor(C>>24&255,C>>16&255,C>>8&255)}i.ensureContrastRatio=e;function t(o){let u=(o.rgba|255)>>>0;return[m,b,_]=B.toChannels(u),{css:g.toCss(m,b,_),rgba:u}}i.opaque=t;function r(o,u){return p=Math.round(u*255),[m,b,_]=B.toChannels(o.rgba),{css:g.toCss(m,b,_,p),rgba:g.toRgba(m,b,_,p)}}i.opacity=r;function l(o,u){return p=o.rgba&255,r(o,p*u/255)}i.multiplyOpacity=l;function s(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=s})(N||={});var x;(t=>{let a,n;try{let r=document.createElement("canvas");r.width=1,r.height=1;let l=r.getContext("2d",{willReadFrequently:!0});l&&(a=l,a.globalCompositeOperation="copy",n=a.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),g.toColor(m,b,_);case 5:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),p=parseInt(r.slice(4,5).repeat(2),16),g.toColor(m,b,_,p);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let l=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return m=parseInt(l[1],10),b=parseInt(l[2],10),_=parseInt(l[3],10),p=Math.round((l[5]===void 0?1:parseFloat(l[5]))*255),g.toColor(m,b,_,p);if(r==="transparent")return{css:"transparent",rgba:0};if(!a||!n)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=n,a.fillStyle=r,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[m,b,_,p]=a.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(m,b,_,p),css:r}}t.toColor=e})(x||={});var v;(e=>{function a(t){return n(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=a;function n(t,r,l){let s=t/255,i=r/255,o=l/255,u=s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),C=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return u*.2126+f*.7152+C*.0722}e.relativeLuminance2=n})(v||={});var B;(l=>{function a(s,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,u=i>>16&255,f=i>>8&255,C=s>>24&255,c=s>>16&255,h=s>>8&255;return m=C+Math.round((o-C)*p),b=c+Math.round((u-c)*p),_=h+Math.round((f-h)*p),g.toRgba(m,b,_)}l.blend=a;function n(s,i,o){let u=v.relativeLuminance(s>>8),f=v.relativeLuminance(i>>8);if(R(u,f)>8));if(I>8));return I>E?d:L}return d}let c=t(s,i,o),h=R(u,v.relativeLuminance(c>>8));if(h>8));return h>I?c:d}return c}}l.ensureContrastRatio=n;function e(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I0||h>0||d>0);)c-=Math.max(0,Math.ceil(c*.1)),h-=Math.max(0,Math.ceil(h*.1)),d-=Math.max(0,Math.ceil(d*.1)),I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));return(c<<24|h<<16|d<<8|255)>>>0}l.reduceLuminance=e;function t(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I>>0}l.increaseLuminance=t;function r(s){return[s>>24&255,s>>16&255,s>>8&255,s&255]}l.toChannels=r})(B||={});function w(a){let n=a.toString(16);return n.length<2?"0"+n:n}function R(a,n){return a{let a=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=n[e/36%6|0],r=n[e/6%6|0],l=n[e%6];a.push({css:g.toCss(t,r,l),rgba:g.toRgba(t,r,l)})}for(let e=0;e<24;e++){let t=8+e*10;a.push({css:g.toCss(t,t,t),rgba:g.toRgba(t,t,t)})}return a})());function A(a,n,e){return Math.max(n,Math.min(a,e))}function z(a){switch(a){case"&":return"&";case"<":return"<"}return a}var S=class{constructor(n){this._buffer=n}serialize(n,e){let t=this._buffer.getNullCell(),r=this._buffer.getNullCell(),l=t,s=n.start.y,i=n.end.y,o=n.start.x,u=n.end.x;this._beforeSerialize(i-s,s,i);for(let f=s;f<=i;f++){let C=this._buffer.getLine(f);if(C){let c=f===n.start.y?o:0,h=f===n.end.y?u:C.length;for(let d=c;d0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`);let r="";if(!t){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let l=this._buffer.getLine(e),s=this._buffer.getLine(e+1);if(!s.isWrapped)r=`\r +-var m=0,b=0,_=0,p=0;var g;(t=>{function a(r,l,s,i){return i!==void 0?`#${w(r)}${w(l)}${w(s)}${w(i)}`:`#${w(r)}${w(l)}${w(s)}`}t.toCss=a;function n(r,l,s,i=255){return(r<<24|l<<16|s<<8|i)>>>0}t.toRgba=n;function e(r,l,s,i){return{css:t.toCss(r,l,s,i),rgba:t.toRgba(r,l,s,i)}}t.toColor=e})(g||={});var N;(i=>{function a(o,u){if(p=(u.rgba&255)/255,p===1)return{css:u.css,rgba:u.rgba};let f=u.rgba>>24&255,C=u.rgba>>16&255,c=u.rgba>>8&255,h=o.rgba>>24&255,d=o.rgba>>16&255,I=o.rgba>>8&255;m=h+Math.round((f-h)*p),b=d+Math.round((C-d)*p),_=I+Math.round((c-I)*p);let L=g.toCss(m,b,_),E=g.toRgba(m,b,_);return{css:L,rgba:E}}i.blend=a;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,u,f){let C=B.ensureContrastRatio(o.rgba,u.rgba,f);if(C)return g.toColor(C>>24&255,C>>16&255,C>>8&255)}i.ensureContrastRatio=e;function t(o){let u=(o.rgba|255)>>>0;return[m,b,_]=B.toChannels(u),{css:g.toCss(m,b,_),rgba:u}}i.opaque=t;function r(o,u){return p=Math.round(u*255),[m,b,_]=B.toChannels(o.rgba),{css:g.toCss(m,b,_,p),rgba:g.toRgba(m,b,_,p)}}i.opacity=r;function l(o,u){return p=o.rgba&255,r(o,p*u/255)}i.multiplyOpacity=l;function s(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=s})(N||={});var x;(t=>{let a,n;try{let r=document.createElement("canvas");r.width=1,r.height=1;let l=r.getContext("2d",{willReadFrequently:!0});l&&(a=l,a.globalCompositeOperation="copy",n=a.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),g.toColor(m,b,_);case 5:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),p=parseInt(r.slice(4,5).repeat(2),16),g.toColor(m,b,_,p);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let l=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return m=parseInt(l[1],10),b=parseInt(l[2],10),_=parseInt(l[3],10),p=Math.round((l[5]===void 0?1:parseFloat(l[5]))*255),g.toColor(m,b,_,p);if(r==="transparent")return{css:"transparent",rgba:0};if(!a||!n)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=n,a.fillStyle=r,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[m,b,_,p]=a.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(m,b,_,p),css:r}}t.toColor=e})(x||={});var v;(e=>{function a(t){return n(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=a;function n(t,r,l){let s=t/255,i=r/255,o=l/255,u=s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),C=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return u*.2126+f*.7152+C*.0722}e.relativeLuminance2=n})(v||={});var B;(l=>{function a(s,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,u=i>>16&255,f=i>>8&255,C=s>>24&255,c=s>>16&255,h=s>>8&255;return m=C+Math.round((o-C)*p),b=c+Math.round((u-c)*p),_=h+Math.round((f-h)*p),g.toRgba(m,b,_)}l.blend=a;function n(s,i,o){let u=v.relativeLuminance(s>>8),f=v.relativeLuminance(i>>8);if(R(u,f)>8));if(I>8));return I>E?d:L}return d}let c=t(s,i,o),h=R(u,v.relativeLuminance(c>>8));if(h>8));return h>I?c:d}return c}}l.ensureContrastRatio=n;function e(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I0||h>0||d>0);)c-=Math.max(0,Math.ceil(c*.1)),h-=Math.max(0,Math.ceil(h*.1)),d-=Math.max(0,Math.ceil(d*.1)),I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));return(c<<24|h<<16|d<<8|255)>>>0}l.reduceLuminance=e;function t(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I>>0}l.increaseLuminance=t;function r(s){return[s>>24&255,s>>16&255,s>>8&255,s&255]}l.toChannels=r})(B||={});function w(a){let n=a.toString(16);return n.length<2?"0"+n:n}function R(a,n){return a{let a=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=n[e/36%6|0],r=n[e/6%6|0],l=n[e%6];a.push({css:g.toCss(t,r,l),rgba:g.toRgba(t,r,l)})}for(let e=0;e<24;e++){let t=8+e*10;a.push({css:g.toCss(t,t,t),rgba:g.toRgba(t,t,t)})}return a})());function A(a,n,e){return Math.max(n,Math.min(a,e))}function z(a){switch(a){case"&":return"&";case"<":return"<"}return a}var S=class{constructor(n){this._buffer=n}serialize(n,e){let t=this._buffer.getNullCell(),r=this._buffer.getNullCell(),l=t,s=n.start.y,i=n.end.y,o=n.start.x,u=n.end.x;this._beforeSerialize(i-s,s,i);for(let f=s;f<=i;f++){let C=this._buffer.getLine(f);if(C){let c=f===n.start.y?o:0,h=f===n.end.y?u:C.length;for(let d=c;d0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`);let r="";if(!t){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let l=this._buffer.getLine(e),s=this._buffer.getLine(e+1);if(!s.isWrapped)r=`\r -`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",r+=`\x1B[${l.length-this._nullCellCount}C`,r+=`\x1B[${this._nullCellCount}X`,r+=`\x1B[${l.length-this._nullCellCount}D`,r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),e.isBold()!==t.isBold()&&r.push(e.isBold()?1:22),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isDim()!==t.isDim()&&r.push(e.isDim()?2:22),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,this._cursorStyle);if(i?!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=e.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; -+`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",r+=`\x1B[${l.length-this._nullCellCount}C`,r+=`\x1B[${this._nullCellCount}X`,r+=`\x1B[${l.length-this._nullCellCount}D`,r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!e.isBold()||d&&!e.isDim();c&&r.push(22),e.isBold()&&(b||c)&&r.push(1),e.isDim()&&(d||c)&&r.push(2)}})(e.isBold()!==t.isBold(),e.isDim()!==t.isDim()),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,this._cursorStyle);if(i?!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=e.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; ++var m=0,b=0,_=0,p=0;var g;(t=>{function a(r,l,s,i){return i!==void 0?`#${w(r)}${w(l)}${w(s)}${w(i)}`:`#${w(r)}${w(l)}${w(s)}`}t.toCss=a;function n(r,l,s,i=255){return(r<<24|l<<16|s<<8|i)>>>0}t.toRgba=n;function e(r,l,s,i){return{css:t.toCss(r,l,s,i),rgba:t.toRgba(r,l,s,i)}}t.toColor=e})(g||={});var N;(i=>{function a(o,u){if(p=(u.rgba&255)/255,p===1)return{css:u.css,rgba:u.rgba};let f=u.rgba>>24&255,C=u.rgba>>16&255,c=u.rgba>>8&255,h=o.rgba>>24&255,d=o.rgba>>16&255,I=o.rgba>>8&255;m=h+Math.round((f-h)*p),b=d+Math.round((C-d)*p),_=I+Math.round((c-I)*p);let L=g.toCss(m,b,_),E=g.toRgba(m,b,_);return{css:L,rgba:E}}i.blend=a;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,u,f){let C=B.ensureContrastRatio(o.rgba,u.rgba,f);if(C)return g.toColor(C>>24&255,C>>16&255,C>>8&255)}i.ensureContrastRatio=e;function t(o){let u=(o.rgba|255)>>>0;return[m,b,_]=B.toChannels(u),{css:g.toCss(m,b,_),rgba:u}}i.opaque=t;function r(o,u){return p=Math.round(u*255),[m,b,_]=B.toChannels(o.rgba),{css:g.toCss(m,b,_,p),rgba:g.toRgba(m,b,_,p)}}i.opacity=r;function l(o,u){return p=o.rgba&255,r(o,p*u/255)}i.multiplyOpacity=l;function s(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=s})(N||={});var x;(t=>{let a,n;try{let r=document.createElement("canvas");r.width=1,r.height=1;let l=r.getContext("2d",{willReadFrequently:!0});l&&(a=l,a.globalCompositeOperation="copy",n=a.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),g.toColor(m,b,_);case 5:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),p=parseInt(r.slice(4,5).repeat(2),16),g.toColor(m,b,_,p);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let l=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return m=parseInt(l[1],10),b=parseInt(l[2],10),_=parseInt(l[3],10),p=Math.round((l[5]===void 0?1:parseFloat(l[5]))*255),g.toColor(m,b,_,p);if(r==="transparent")return{css:"transparent",rgba:0};if(!a||!n)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=n,a.fillStyle=r,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[m,b,_,p]=a.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(m,b,_,p),css:r}}t.toColor=e})(x||={});var v;(e=>{function a(t){return n(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=a;function n(t,r,l){let s=t/255,i=r/255,o=l/255,u=s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),C=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return u*.2126+f*.7152+C*.0722}e.relativeLuminance2=n})(v||={});var B;(l=>{function a(s,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,u=i>>16&255,f=i>>8&255,C=s>>24&255,c=s>>16&255,h=s>>8&255;return m=C+Math.round((o-C)*p),b=c+Math.round((u-c)*p),_=h+Math.round((f-h)*p),g.toRgba(m,b,_)}l.blend=a;function n(s,i,o){let u=v.relativeLuminance(s>>8),f=v.relativeLuminance(i>>8);if(R(u,f)>8));if(I>8));return I>E?d:L}return d}let c=t(s,i,o),h=R(u,v.relativeLuminance(c>>8));if(h>8));return h>I?c:d}return c}}l.ensureContrastRatio=n;function e(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I0||h>0||d>0);)c-=Math.max(0,Math.ceil(c*.1)),h-=Math.max(0,Math.ceil(h*.1)),d-=Math.max(0,Math.ceil(d*.1)),I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));return(c<<24|h<<16|d<<8|255)>>>0}l.reduceLuminance=e;function t(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I>>0}l.increaseLuminance=t;function r(s){return[s>>24&255,s>>16&255,s>>8&255,s&255]}l.toChannels=r})(B||={});function w(a){let n=a.toString(16);return n.length<2?"0"+n:n}function R(a,n){return a{let a=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=n[e/36%6|0],r=n[e/6%6|0],l=n[e%6];a.push({css:g.toCss(t,r,l),rgba:g.toRgba(t,r,l)})}for(let e=0;e<24;e++){let t=8+e*10;a.push({css:g.toCss(t,t,t),rgba:g.toRgba(t,t,t)})}return a})());function A(a,n,e){return Math.max(n,Math.min(a,e))}function z(a){switch(a){case"&":return"&";case"<":return"<"}return a}var S=class{constructor(n){this._buffer=n}serialize(n,e){let t=this._buffer.getNullCell(),r=this._buffer.getNullCell(),l=t,s=n.start.y,i=n.end.y,o=n.start.x,u=n.end.x;this._beforeSerialize(i-s,s,i);for(let f=s;f<=i;f++){let C=this._buffer.getLine(f);if(C){let c=f===n.start.y?o:0,h=f===n.end.y?u:C.length;for(let d=c;d0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`);let r="";if(!t){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let l=this._buffer.getLine(e),s=this._buffer.getLine(e+1);if(!s.isWrapped)r=`\r ++`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&(f?this._nullCellCount<=1:this._nullCellCount<=0))&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",l.length-this._nullCellCount>0&&(r+=`\x1B[${l.length-this._nullCellCount}C`),r+=`\x1B[${this._nullCellCount}X`,l.length-this._nullCellCount>0&&(r+=`\x1B[${l.length-this._nullCellCount}D`),r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!e.isBold()||d&&!e.isDim();c&&r.push(22),e.isBold()&&(b||c)&&r.push(1),e.isDim()&&(d||c)&&r.push(2)}})(e.isBold()!==t.isBold(),e.isDim()!==t.isDim()),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",f=i&&e.isInverse()?this._buffer.getLine(r+1):void 0,g=f?.getCell(0,this._nextRowFirstChar),C=l===this._terminal.cols-1&&f?.isWrapped&&(g?.getWidth()??0)>1&&!!g&&U(e,g),q=i&&!!e.isInverse()&&!C,d=q&&(!!e.isUnderline()||!!e.isStrikethrough()||!!e.isOverline()),o=this._diffStyle(e,this._cursorStyle);if(i?q?o.length>0:!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i&&!q?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),(q?(d&&(this._currentRow+="\x1B[24;29;55m"),this._currentRow+=" ".repeat(e.getWidth()),d&&(this._currentRow+=`\x1B[0m\x1B[${this._diffStyle(e,this._defaultCell).join(";")}m`)):this._currentRow+=e.getChars()),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; //# sourceMappingURL=addon-serialize.mjs.map diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts -index e1728feb219c362dfa2ecb602ff99f830d520757..672fdd5b86d73d8694446138835bbeccd43cfaae 100644 +index e1728feb219c362dfa2ecb602ff99f830d520757..beaff8dd2c985a1e7378d42683c9940865057d6a 100644 --- a/src/SerializeAddon.ts +++ b/src/SerializeAddon.ts -@@ -310,7 +310,20 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -148,6 +148,7 @@ class StringSerializeHandler extends BaseSerializeHandler { + + // this is a null cell for reference for checking whether background is empty or not + private _backgroundCell: IBufferCell = this._buffer.getNullCell(); ++ private _defaultCell: IBufferCell = this._buffer.getNullCell(); + + private _firstRow: number = 0; + private _lastCursorRow: number = 0; +@@ -214,7 +215,7 @@ class StringSerializeHandler extends BaseSerializeHandler { + if ( + // you must output character to cause overflow, control sequence can't do this + nextRowFirstChar.getChars() && +- isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0 ++ (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0) + ) { + if ( + // the last character can't be null, +@@ -251,9 +252,14 @@ class StringSerializeHandler extends BaseSerializeHandler { + if (this._nullCellCount > 0) { + // do these because we filled the last several null slot, which we shouldn't + rowSeparator += '\u001b[A'; +- rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`; ++ const contentCellCount = currentLine.length - this._nullCellCount; ++ if (contentCellCount > 0) { ++ rowSeparator += `\u001b[${contentCellCount}C`; ++ } + rowSeparator += `\u001b[${this._nullCellCount}X`; +- rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D`; ++ if (contentCellCount > 0) { ++ rowSeparator += `\u001b[${contentCellCount}D`; ++ } + rowSeparator += '\u001b[B'; + } + +@@ -310,7 +316,20 @@ class StringSerializeHandler extends BaseSerializeHandler { } if (flagsChanged) { if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } @@ -44,7 +80,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..672fdd5b86d73d8694446138835bbecc if (!equalUnderline(cell, oldCell)) { const style = cell.getUnderlineStyle(); if (style === UnderlineStyle.NONE) { -@@ -337,7 +350,7 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -337,7 +356,7 @@ class StringSerializeHandler extends BaseSerializeHandler { if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); } if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); } if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); } @@ -53,3 +89,58 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..672fdd5b86d73d8694446138835bbecc if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); } } } +@@ -356,12 +375,21 @@ class StringSerializeHandler extends BaseSerializeHandler { + + // this cell don't have content + const isEmptyCell = cell.getChars() === ''; ++ const nextLine = isEmptyCell && cell.isInverse() ? this._buffer.getLine(row + 1) : undefined; ++ const nextRowFirstCell = nextLine?.getCell(0, this._nextRowFirstChar); ++ // A pending wide glyph recreates its own final-column padding during replay. ++ const isWideWrapPadding = col === this._terminal.cols - 1 && ++ nextLine?.isWrapped && ++ (nextRowFirstCell?.getWidth() ?? 0) > 1 && ++ !!nextRowFirstCell && attributesEquals(cell, nextRowFirstCell); ++ // Cursor movement cannot reproduce an inverse cell's visible background. ++ const materializeEmptyCell = isEmptyCell && !!cell.isInverse() && !isWideWrapPadding; + + const sgrSeq = this._diffStyle(cell, this._cursorStyle); + +- // the empty cell style is only assumed to be changed when background changed, because +- // foreground is always 0. +- const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0; ++ const styleChanged = isEmptyCell ++ ? materializeEmptyCell ? sgrSeq.length > 0 : !equalBg(this._cursorStyle, cell) ++ : sgrSeq.length > 0; + + /** + * handles style change +@@ -395,7 +423,7 @@ class StringSerializeHandler extends BaseSerializeHandler { + /** + * handles actual content + */ +- if (isEmptyCell) { ++ if (isEmptyCell && !materializeEmptyCell) { + this._nullCellCount += cell.getWidth(); + } else { + if (this._nullCellCount > 0) { +@@ -411,7 +439,19 @@ class StringSerializeHandler extends BaseSerializeHandler { + this._nullCellCount = 0; + } + +- this._currentRow += cell.getChars(); ++ if (materializeEmptyCell) { ++ const hasDecoration = !!cell.isUnderline() || !!cell.isStrikethrough() || !!cell.isOverline(); ++ if (hasDecoration) { ++ this._currentRow += '\u001b[24;29;55m'; ++ } ++ this._currentRow += ' '.repeat(cell.getWidth()); ++ if (hasDecoration) { ++ const restoreSgrSeq = this._diffStyle(cell, this._defaultCell); ++ this._currentRow += `\u001b[0m\u001b[${restoreSgrSeq.join(';')}m`; ++ } ++ } else { ++ this._currentRow += cell.getChars(); ++ } + + // update cursor + this._lastContentCursorRow = this._lastCursorRow = row; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd1f3e41e4..f3db5addebb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ patchedDependencies: hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch '@xterm/addon-serialize@0.15.0-beta.287': - hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9 + hash: 96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch '@xterm/addon-webgl@0.20.0-beta.286': hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258 @@ -46,7 +46,7 @@ importers: version: 2.5.6 '@xterm/addon-serialize': specifier: 0.15.0-beta.287 - version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)) + version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)) '@xterm/headless': specifier: 6.1.0-beta.287 version: 6.1.0-beta.287 @@ -9596,7 +9596,7 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8) - '@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))': + '@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))': dependencies: '@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8) diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index c2bbc8bfa34..07f291a60ff 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(31) + expect(PROTOCOL_VERSION).toBe(32) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -14,5 +14,6 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(30) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(31) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index aa8b58e11ca..53ad0c06341 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -6,6 +6,7 @@ import { GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, + SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, @@ -14,7 +15,8 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(31) + expect(PROTOCOL_VERSION).toBe(32) + expect(SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION).toBe(32) expect(STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION).toBe(31) expect(HISTORY_SEED_TRANSFER_PROTOCOL_VERSION).toBe(30) expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29) @@ -23,7 +25,7 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 30 }, (_, index) => index + 1) + Array.from({ length: 31 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 1f4195af37b..61cabf2fc15 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,7 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v31 exposes attach-only PTY adoption so a mount cannot create over a live stable pane. -export const PROTOCOL_VERSION = 31 +// v32 carries the corrected terminal snapshot serializer; older owners remain attachable. +export const PROTOCOL_VERSION = 32 +export const SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION = 32 export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31 export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30 export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27 @@ -23,7 +24,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30 + 28, 29, 30, 31 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 8525542a9eb..5dc807459be 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -848,13 +848,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) - it('delegates scan authority to a v29 daemon, which can retract', () => { + it('delegates scan authority to a v32 daemon with faithful snapshots', () => { const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') - const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 32 }) try { - current.setPtyBackgrounded('v29-session', true) + current.setPtyBackgrounded('v32-session', true) expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { - sessionId: 'v29-session', + sessionId: 'v32-session', background: true }) } finally { @@ -939,8 +939,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) describe('background stream thinning compatibility', () => { - it('reports authoritative snapshot support only for protocol v20 and newer', () => { - const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + it('reports authoritative snapshot support only for the corrected serializer protocol', () => { + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 31 }) try { expect(legacy.canProvideAuthoritativeBufferSnapshot('legacy-session')).toBe(false) expect(adapter.canProvideAuthoritativeBufferSnapshot('current-session')).toBe(true) @@ -962,9 +962,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) - it('keeps preserved v19 sessions unthinned because their snapshots have no sequence', () => { + it('keeps preserved v31 sessions unthinned because their snapshots can corrupt replay', () => { const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') - const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 31 }) try { legacy.setPtyBackgrounded('legacy-session', true) expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { @@ -977,7 +977,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) - it('clears a preserved v19 background hint before attaching its stream', async () => { + it('clears a preserved v31 background hint before attaching its stream', async () => { const ensureConnectedSpy = vi .spyOn(DaemonClient.prototype, 'ensureConnected') .mockResolvedValue() @@ -988,7 +988,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { snapshot: null } as never) const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') - const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 31 }) try { await legacy.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 }) @@ -1009,7 +1009,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it.each([ { protocolVersion: 28, clearsHint: true }, - { protocolVersion: 29, clearsHint: false } + { protocolVersion: 29, clearsHint: true }, + { protocolVersion: 31, clearsHint: true }, + { protocolVersion: 32, clearsHint: false } ])( 'uses protocol v$protocolVersion background authority before spawn', async ({ protocolVersion, clearsHint }) => { @@ -1085,7 +1087,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) - it('leaves a v29 background hint alone on attach, because it can retract on its own', async () => { + it('leaves a v32 background hint alone on attach', async () => { const ensureConnectedSpy = vi .spyOn(DaemonClient.prototype, 'ensureConnected') .mockResolvedValue() @@ -1096,13 +1098,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { snapshot: null } as never) const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') - const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 32 }) try { - await current.attach('preserved-v29-session') + await current.attach('preserved-v32-session') expect(notifySpy).not.toHaveBeenCalledWith( 'setSessionBackground', - expect.objectContaining({ sessionId: 'preserved-v29-session' }) + expect.objectContaining({ sessionId: 'preserved-v32-session' }) ) } finally { current.dispose() @@ -1112,7 +1114,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) - it('still returns the v19 attach snapshot for a desktop renderer replay', async () => { + it('still returns the v31 attach snapshot so desktop replay retains shell history', async () => { const ensureConnectedSpy = vi .spyOn(DaemonClient.prototype, 'ensureConnected') .mockResolvedValue() @@ -1129,7 +1131,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { rows: 24 } } as never) - const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 31 }) try { const result = await legacy.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 012e5c74b01..2bba3c467dc 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -38,6 +38,7 @@ import { } from './types' import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, + SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' import { @@ -276,7 +277,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.supportsCheckpoints = this.protocolVersion >= 4 this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 this.supportsProducerFlowControl = this.protocolVersion >= 19 - this.supportsAuthoritativeBufferSnapshots = this.protocolVersion >= 20 + this.supportsAuthoritativeBufferSnapshots = + this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion) this.client.onDisconnected(() => { if (!this.respawnAdoptionClosed) { @@ -959,7 +961,7 @@ export class DaemonPtyAdapter implements IPtyProvider { if (!this.supportsProducerFlowControl) { return } - // Why: preserved v19 daemons can thin but can't return the absolute snapshot sequence to recover a gap; clear their stale hint too. + // Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream. // Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan // authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never // retract it, so a TUI exiting while hidden would strand the subscription and the diff --git a/src/main/daemon/daemon-pty-router-history-handoff.test.ts b/src/main/daemon/daemon-pty-router-history-handoff.test.ts index a53a4b29b63..07c2a5033d5 100644 --- a/src/main/daemon/daemon-pty-router-history-handoff.test.ts +++ b/src/main/daemon/daemon-pty-router-history-handoff.test.ts @@ -121,9 +121,7 @@ describe('DaemonPtyRouter history handoff', () => { 'finishHistorySeedTransfer' ]) ) - await expect(router.getBufferSnapshot(sessionId)).resolves.toMatchObject({ - data: expect.stringContaining(marker) - }) + await expect(router.getBufferSnapshot(sessionId)).resolves.toBeNull() expect(currentAdapter.getHistoryManager()?.hasWriter(sessionId)).toBe(true) }) }) diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 17ad0dd835c..2bee5164ce9 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -7,7 +7,12 @@ import { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } from './types' -import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version' +import { + HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, + PROTOCOL_VERSION, + SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, + STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION +} from './daemon-protocol-version' type AdapterMock = DaemonPtyAdapter & { emitData: (id: string, data: string, sequenceChars?: number) => void @@ -49,7 +54,8 @@ function createAdapter( protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, providesAgentSessionOwnerListings: () => protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - canProvideAuthoritativeBufferSnapshot: () => protocolVersion >= 20, + canProvideAuthoritativeBufferSnapshot: () => + protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { const id = opts.sessionId ?? `${label}-new` sessions.push(id) @@ -336,8 +342,13 @@ describe('DaemonPtyRouter', () => { }) it('reports snapshot capability for the adapter that owns each session', async () => { - const current = createAdapter('current', ['current-session'], undefined, 22) - const legacy = createAdapter('legacy', ['legacy-session'], undefined, 19) + const current = createAdapter('current', ['current-session'], undefined, PROTOCOL_VERSION) + const legacy = createAdapter( + 'legacy', + ['legacy-session'], + undefined, + STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION + ) const router = new DaemonPtyRouter({ current, legacy: [legacy] }) await router.discoverLegacySessions() @@ -371,21 +382,36 @@ describe('DaemonPtyRouter', () => { expect(current.confirmForegroundProcess).toHaveBeenCalledWith('current-session') }) - it('routes existing legacy sessions to their old daemon and new sessions to current daemon', async () => { - const current = createAdapter('current') - const legacy = createAdapter('legacy', ['legacy-session']) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + it('preserves v30/v31 session owners and routes new sessions to v32', async () => { + const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) + const legacyV30 = createAdapter( + 'v30', + ['v30-session'], + undefined, + HISTORY_SEED_TRANSFER_PROTOCOL_VERSION + ) + const legacyV31 = createAdapter( + 'v31', + ['v31-session'], + undefined, + STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION + ) + const router = new DaemonPtyRouter({ current, legacy: [legacyV30, legacyV31] }) await router.discoverLegacySessions() - await router.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 }) + await router.spawn({ sessionId: 'v30-session', cols: 80, rows: 24 }) + await router.spawn({ sessionId: 'v31-session', cols: 80, rows: 24 }) const fresh = await router.spawn({ cols: 80, rows: 24 }) - router.write('legacy-session', 'old\n') + router.write('v30-session', 'old-v30\n') + router.write('v31-session', 'old-v31\n') router.write(fresh.id, 'new\n') - expect(legacy.spawn).toHaveBeenCalledWith({ sessionId: 'legacy-session', cols: 80, rows: 24 }) + expect(legacyV30.spawn).toHaveBeenCalledWith({ sessionId: 'v30-session', cols: 80, rows: 24 }) + expect(legacyV31.spawn).toHaveBeenCalledWith({ sessionId: 'v31-session', cols: 80, rows: 24 }) expect(current.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 }) - expect(legacy.write).toHaveBeenCalledWith('legacy-session', 'old\n') + expect(legacyV30.write).toHaveBeenCalledWith('v30-session', 'old-v30\n') + expect(legacyV31.write).toHaveBeenCalledWith('v31-session', 'old-v31\n') expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n') }) diff --git a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts index 3eb708b3cf2..7642e958eec 100644 --- a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts +++ b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts @@ -10,10 +10,10 @@ import { POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, SNAPSHOT_REPLAY_PREAMBLE_ALT, SNAPSHOT_REPLAY_PREAMBLE_NORMAL, - bufferHasSerializeHostileWrappedRow, createRendererParityTerminal, cursorPosition, normalBufferRowsTrimmed, + visibleRowWraps, visibleRowStyles, visibleRows, writeChunksToTerminal @@ -56,8 +56,6 @@ type FidelityDiff = { stage: string expected: unknown actual: unknown - /** True when the buffer trips the known upstream addon-serialize blank-leading-wrapped-row bug (see bufferHasSerializeHostileWrappedRow). */ - knownSerializeWrapBug?: boolean } function buildCase(seed: number): FidelityCase { @@ -155,9 +153,6 @@ async function runFidelityCase(testCase: FidelityCase): Promise { }) it(`matches an always-visible renderer twin across ${ITERATIONS} seeded agent-TUI streams`, async () => { - // Bug A (serialize wrap) is tolerated + counted so deep mode surfaces only new divergences; B and C are fixed, so their regressions fail loudly. - let knownSerializeWrapBugHits = 0 for (let i = 0; i < ITERATIONS; i++) { const seed = FIXED_SEED ?? 1 + i const testCase = buildCase(seed) const diff = await runFidelityCase(testCase) - if (diff?.knownSerializeWrapBug) { - knownSerializeWrapBugHits += 1 - continue - } if (diff) { const minimized = await minimizeFailure(testCase) const minimizedDiff = await runFidelityCase(minimized) expect.fail(formatFailure(minimized, minimizedDiff ?? diff)) } } - // Guard the tolerance: the predicate tripping on most seeds means the gate has gone degenerate. - expect(knownSerializeWrapBugHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5)) }, 600_000) - // HEADLINE FINDING (do not delete while unfixed upstream): @xterm/addon-serialize 0.15.0-beta.287 - // doesn't round-trip null cells at a soft-wrap boundary, garbling every snapshot restore of a TUI that - // erased inside a soft-wrapped line. Repros below — V1 cell loss (seed 31, SerializeAddon.ts ~L214 - // wrap-validity ternary), V2 stray '-' filler (seed 157). Unskip once the upstream fix lands. - it.skip('round-trips a wrapped line whose continuation row starts with an erased cell', async () => { + it('round-trips a wrapped line whose continuation row starts with an erased cell', async () => { const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) const control = createRendererParityTerminal({ cols: 20, rows: 6 }) const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) @@ -267,6 +250,9 @@ describe('headless emulator snapshot fidelity fuzz', () => { POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY ]) expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(control.terminal)) + expect(visibleRowWraps(restored.terminal)).toEqual(visibleRowWraps(control.terminal)) + expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(control.terminal)) } finally { emulator.dispose() control.terminal.dispose() @@ -274,8 +260,7 @@ describe('headless emulator snapshot fidelity fuzz', () => { } }) - // V2 repro of the headline finding (stray '-' on a fully erased wrapped source row); unskip with V1. - it.skip('round-trips a wrapped line whose source row was fully erased', async () => { + it('round-trips a wrapped line whose source row was fully erased', async () => { const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) const control = createRendererParityTerminal({ cols: 20, rows: 6 }) const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) @@ -292,8 +277,10 @@ describe('headless emulator snapshot fidelity fuzz', () => { snapshot.rehydrateSequences + snapshot.snapshotAnsi, POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY ]) - // Fails today: restored row 0 shows '-' where the live row is blank. expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(control.terminal)) + expect(visibleRowWraps(restored.terminal)).toEqual(visibleRowWraps(control.terminal)) + expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(control.terminal)) } finally { emulator.dispose() control.terminal.dispose() diff --git a/src/main/daemon/terminal-snapshot-color-parity.test.ts b/src/main/daemon/terminal-snapshot-color-parity.test.ts new file mode 100644 index 00000000000..576a20db431 --- /dev/null +++ b/src/main/daemon/terminal-snapshot-color-parity.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest' +import { + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + SNAPSHOT_REPLAY_PREAMBLE_ALT, + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + buildParityMainBufferSnapshot, + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + normalBufferStylesTrimmed, + visibleRowStyles, + visibleRowWraps, + visibleRows, + writeChunksToTerminal +} from '../../shared/terminal-restore-parity-fixture' + +type ParityTerminal = ReturnType + +async function restoreSerializedTerminal(source: ParityTerminal): Promise { + const snapshot = buildParityMainBufferSnapshot(source, 1, { scrollbackRows: 5000 }) + const restored = createRendererParityTerminal({ cols: snapshot.cols, rows: snapshot.rows }) + const preamble = + snapshot.alternateScreen && snapshot.scrollbackAnsi !== undefined + ? `\x1b[?1049l\x1b[2J\x1b[3J\x1b[H${snapshot.scrollbackAnsi}${SNAPSHOT_REPLAY_PREAMBLE_ALT}` + : snapshot.alternateScreen + ? SNAPSHOT_REPLAY_PREAMBLE_ALT + : SNAPSHOT_REPLAY_PREAMBLE_NORMAL + await writeChunksToTerminal(restored.terminal, [ + preamble, + snapshot.data, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + return restored +} + +function expectActiveBufferParity(source: ParityTerminal, restored: ParityTerminal): void { + expect(visibleRows(restored.terminal)).toEqual(visibleRows(source.terminal)) + expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(source.terminal)) + expect(visibleRowWraps(restored.terminal)).toEqual(visibleRowWraps(source.terminal)) + expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(source.terminal)) +} + +describe('terminal snapshot color parity', () => { + it('preserves Codex truecolor backgrounds, BCE rows, and default trailing cells', async () => { + const source = createRendererParityTerminal({ cols: 24, rows: 6 }) + let restored: ParityTerminal | undefined + try { + await writeChunksToTerminal(source.terminal, [ + '\x1b[48;2;33;58;43m\x1b[2K\x1b[0m', + '\x1b[2;1H\x1b[48;2;74;34;29mremoved\x1b[0m', + '\x1b[3;1H\x1b[48;2;65;69;76m> prompt\x1b[0m', + '\x1b[4;1H\x1b[48;2;33;58;43madded\x1b[K\x1b[0m' + ]) + restored = await restoreSerializedTerminal(source) + + expectActiveBufferParity(source, restored) + const buffer = restored.terminal.buffer.active + expect(buffer.getLine(0)?.getCell(0)?.getBgColor()).toBe(0x213a2b) + expect(buffer.getLine(0)?.getCell(23)?.getBgColor()).toBe(0x213a2b) + expect(buffer.getLine(1)?.getCell(6)?.getBgColor()).toBe(0x4a221d) + expect(buffer.getLine(1)?.getCell(7)?.getBgColor()).toBe(-1) + expect(buffer.getLine(2)?.getCell(0)?.getBgColor()).toBe(0x41454c) + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) + + it('preserves styled scrollback in the normal buffer', async () => { + const source = createRendererParityTerminal({ cols: 18, rows: 3 }) + let restored: ParityTerminal | undefined + try { + const lines = Array.from( + { length: 7 }, + (_, index) => + `\x1b[48;2;${33 + index};${58 + index};${43 + index}mline-${index}\x1b[0m${index < 6 ? '\r\n' : ''}` + ) + await writeChunksToTerminal(source.terminal, lines) + restored = await restoreSerializedTerminal(source) + + expect(source.terminal.buffer.normal.baseY).toBeGreaterThan(0) + expectActiveBufferParity(source, restored) + expect(normalBufferRowsTrimmed(restored.terminal)).toEqual( + normalBufferRowsTrimmed(source.terminal) + ) + expect(normalBufferStylesTrimmed(restored.terminal)).toEqual( + normalBufferStylesTrimmed(source.terminal) + ) + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) + + it('preserves inverse null backgrounds without materializing wide-wrap padding', async () => { + const source = createRendererParityTerminal({ cols: 10, rows: 4 }) + let restored: ParityTerminal | undefined + try { + await writeChunksToTerminal(source.terminal, [`\x1b[7m${'A'.repeat(9)}你\x1b[0m`]) + const sourcePadding = source.terminal.buffer.active.getLine(0)?.getCell(9) + expect(sourcePadding?.getChars()).toBe('') + expect(sourcePadding?.isInverse()).toBeTruthy() + expect(source.terminal.buffer.active.getLine(1)?.isWrapped).toBe(true) + + restored = await restoreSerializedTerminal(source) + + expectActiveBufferParity(source, restored) + const restoredPadding = restored.terminal.buffer.active.getLine(0)?.getCell(9) + expect(restoredPadding?.getChars()).toBe('') + expect(restoredPadding?.isInverse()).toBeTruthy() + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) + + it('materializes final-column nulls that do not match the next wrapped glyph', async () => { + const source = createRendererParityTerminal({ cols: 10, rows: 4 }) + let restored: ParityTerminal | undefined + try { + await writeChunksToTerminal(source.terminal, ['123456789你\x1b[1;9H\x1b[7m界\x1b[1;9HX']) + const sourceNull = source.terminal.buffer.active.getLine(0)?.getCell(9) + const nextGlyph = source.terminal.buffer.active.getLine(1)?.getCell(0) + expect(sourceNull?.getChars()).toBe('') + expect(sourceNull?.isInverse()).toBeTruthy() + expect(nextGlyph?.getWidth()).toBe(2) + expect(nextGlyph?.isInverse()).toBeFalsy() + + restored = await restoreSerializedTerminal(source) + + expect(visibleRows(restored.terminal).map((row) => row.trimEnd())).toEqual( + visibleRows(source.terminal).map((row) => row.trimEnd()) + ) + expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(source.terminal)) + expect(visibleRowWraps(restored.terminal)).toEqual(visibleRowWraps(source.terminal)) + expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(source.terminal)) + const restoredBlank = restored.terminal.buffer.active.getLine(0)?.getCell(9) + expect(restoredBlank?.getChars()).toBe(' ') + expect(restoredBlank?.isInverse()).toBeTruthy() + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) + + it('suppresses null-cell decorations when materializing an inverse background', async () => { + const source = createRendererParityTerminal({ cols: 40, rows: 4 }) + let restored: ParityTerminal | undefined + try { + await writeChunksToTerminal(source.terminal, [ + `\x1b[1;30H你\x1b[H\x1b[7;4;9;53m${'A'.repeat(30)}\x1b[0m\x1b[CZ` + ]) + const sourceNull = source.terminal.buffer.active.getLine(0)?.getCell(30) + expect(sourceNull?.getChars()).toBe('') + expect(sourceNull?.isInverse()).toBeTruthy() + expect(sourceNull?.isUnderline()).toBeTruthy() + expect(sourceNull?.isStrikethrough()).toBeTruthy() + expect(sourceNull?.isOverline()).toBeTruthy() + + restored = await restoreSerializedTerminal(source) + + expectActiveBufferParity(source, restored) + const restoredBlank = restored.terminal.buffer.active.getLine(0)?.getCell(30) + expect(restoredBlank?.getChars()).toBe(' ') + expect(restoredBlank?.isInverse()).toBeTruthy() + expect(restoredBlank?.isUnderline()).toBeFalsy() + expect(restoredBlank?.isStrikethrough()).toBeFalsy() + expect(restoredBlank?.isOverline()).toBeFalsy() + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) + + it('preserves the normal buffer and colored alternate-screen frame', async () => { + const source = createRendererParityTerminal({ cols: 24, rows: 5 }) + let restored: ParityTerminal | undefined + try { + await writeChunksToTerminal(source.terminal, [ + '\x1b[48;2;74;34;29mshell history\x1b[0m\r\nsecond line', + '\x1b[?1049h\x1b[2J\x1b[H', + '\x1b[48;2;65;69;76m\x1b[2K\x1b[0m', + '\x1b[2;1H\x1b[48;2;33;58;43mworking\x1b[K\x1b[0m' + ]) + restored = await restoreSerializedTerminal(source) + + expect(source.terminal.buffer.active.type).toBe('alternate') + expect(restored.terminal.buffer.active.type).toBe('alternate') + expectActiveBufferParity(source, restored) + expect(normalBufferRowsTrimmed(restored.terminal)).toEqual( + normalBufferRowsTrimmed(source.terminal) + ) + expect(normalBufferStylesTrimmed(restored.terminal)).toEqual( + normalBufferStylesTrimmed(source.terminal) + ) + expect(restored.terminal.buffer.active.getLine(1)?.getCell(23)?.getBgColor()).toBe(0x213a2b) + } finally { + source.terminal.dispose() + restored?.terminal.dispose() + } + }) +}) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 16d4717040e..3f60ec66720 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6754,6 +6754,64 @@ describe('registerPtyHandlers', () => { ]) }) + it('waits for local provider startup before resolving snapshot capability', async () => { + const barrier = makeDeferred() + const awaitLocalPtyProviderStartup = vi.fn(() => barrier.promise) + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + undefined, + { awaitLocalPtyProviderStartup } + ) + const pending = Promise.resolve( + handlers.get('pty:getAuthoritativeBufferSnapshotCapabilities')?.(null, { + ids: ['restored-local-pty'] + }) + ) + let settled = false + void pending.then(() => { + settled = true + }) + + await Promise.resolve() + expect(awaitLocalPtyProviderStartup).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + + installDaemonTestProvider({ canProvideAuthoritativeBufferSnapshot: () => true }) + barrier.resolve() + + await expect(pending).resolves.toEqual([{ id: 'restored-local-pty', authoritative: true }]) + }) + + it('does not gate remote snapshot capability on local provider startup', async () => { + const awaitLocalPtyProviderStartup = vi.fn(() => new Promise(() => {})) + registerSshPtyProvider('ssh-1', { + canProvideAuthoritativeBufferSnapshot: () => false + } as never) + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + undefined, + { awaitLocalPtyProviderStartup } + ) + + const result = await handlers.get('pty:getAuthoritativeBufferSnapshotCapabilities')?.(null, { + ids: ['remote:environment@@pty-1', 'ssh:ssh-1@@pty-2'] + }) + + expect(awaitLocalPtyProviderStartup).not.toHaveBeenCalled() + expect(result).toEqual([ + { id: 'remote:environment@@pty-1', authoritative: false }, + { id: 'ssh:ssh-1@@pty-2', authoritative: false } + ]) + }) + it('answers false, not null, for a resolved provider with no snapshot capability', async () => { // Null is never cached, so missing optional methods must resolve false. registerPtyHandlers(mainWindow as never) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 2608e54f386..401852169ec 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -6969,8 +6969,24 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:getAuthoritativeBufferSnapshotCapabilities', - (_event, args: { ids?: unknown }) => { + async (_event, args: { ids?: unknown }) => { const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : [] + const hasLocalPtyId = ids.some((value) => { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + value.startsWith('remote:') || + parseAppSshPtyId(value) + ) { + return false + } + const ownedConnectionId = ptyOwnership.get(value) + return ownedConnectionId === undefined || ownedConnectionId === null + }) + if (hasLocalPtyId) { + await getLocalPtyProviderStartupPromise() + } const capabilities: { id: string; authoritative: boolean | null }[] = [] const seen = new Set() for (const value of ids) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a57e7c90f53..b9765d9149c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -215,7 +215,7 @@ import { } from './components/terminal/background-terminal-worktree-mount' import { collectTerminalProviderSnapshotPtyIds, - synchronizeTerminalProviderSnapshotCapabilities + refreshTerminalProviderSnapshotCapabilities } from './components/terminal/terminal-provider-snapshot-capability' import { useRemoteRuntimeRecoveryTriggers } from './runtime/use-remote-runtime-recovery-triggers' @@ -1081,7 +1081,7 @@ function App(): React.JSX.Element { window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) await timeRendererStartupStep('terminal-provider-snapshot-capabilities', () => { - return synchronizeTerminalProviderSnapshotCapabilities( + return refreshTerminalProviderSnapshotCapabilities( collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) ) }) @@ -1170,6 +1170,9 @@ function App(): React.JSX.Element { try { await window.api.app.awaitFirstWindowStartupServices() await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() + await refreshTerminalProviderSnapshotCapabilities( + collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) + ) await actions.reconnectPersistedTerminals(abortController.signal) await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() } catch (reconnectErr) { diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index bfe17737970..80bb0037eac 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -139,6 +139,10 @@ describe('renderer startup runtime routing', () => { 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()', servicesIndex ) + const capabilityRefreshIndex = source.indexOf( + 'refreshTerminalProviderSnapshotCapabilities(', + preReconnectRecoveryIndex + ) const reconnectIndex = source.indexOf( 'actions.reconnectPersistedTerminals(abortController.signal)', preReconnectRecoveryIndex @@ -150,10 +154,40 @@ describe('renderer startup runtime routing', () => { expect(servicesIndex).toBeGreaterThanOrEqual(0) expect(preReconnectRecoveryIndex).toBeGreaterThan(servicesIndex) - expect(reconnectIndex).toBeGreaterThan(preReconnectRecoveryIndex) + expect(capabilityRefreshIndex).toBeGreaterThan(preReconnectRecoveryIndex) + expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) expect(postReconnectRecoveryIndex).toBeGreaterThan(reconnectIndex) }) + it('refreshes terminal snapshot capability before degraded reconnect', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const degradedStart = source.indexOf( + '[startup] Workspace session hydration failed; leaving disk state untouched:' + ) + const servicesIndex = source.indexOf( + 'await window.api.app.awaitFirstWindowStartupServices()', + degradedStart + ) + const recoveryIndex = source.indexOf( + 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()', + servicesIndex + ) + const capabilityRefreshIndex = source.indexOf( + 'refreshTerminalProviderSnapshotCapabilities(', + recoveryIndex + ) + const reconnectIndex = source.indexOf( + 'actions.reconnectPersistedTerminals(abortController.signal)', + recoveryIndex + ) + + expect(degradedStart).toBeGreaterThanOrEqual(0) + expect(servicesIndex).toBeGreaterThan(degradedStart) + expect(recoveryIndex).toBeGreaterThan(servicesIndex) + expect(capabilityRefreshIndex).toBeGreaterThan(recoveryIndex) + expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) + }) + it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => { const source = readFileSync( join(process.cwd(), 'src/renderer/src/components/automations/AutomationsPage.tsx'), diff --git a/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts index f5c55cc72cd..d17155f9d45 100644 --- a/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts +++ b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts @@ -10,7 +10,6 @@ import { POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, SNAPSHOT_REPLAY_PREAMBLE_ALT, SNAPSHOT_REPLAY_PREAMBLE_NORMAL, - bufferHasSerializeHostileWrappedRow, buildParityMainBufferSnapshot, createRendererParityTerminal, cursorPosition, @@ -86,7 +85,6 @@ type RevealResult = { /** True when the terminal ended on the alternate screen: alt has no scrollback, so the normal-buffer content comparison does not apply. */ alternate: boolean forcedFreshRestore: boolean - knownSerializeWrapBug: boolean } async function readScreen( @@ -100,8 +98,7 @@ async function readScreen( cursor: cursorPosition(term.terminal), scrolled: term.terminal.buffer.active.baseY > 0, alternate, - forcedFreshRestore: false, - knownSerializeWrapBug: false + forcedFreshRestore: false } } @@ -133,7 +130,6 @@ async function revealFromSnapshot( pendingEscapeTail: extractPartialEscapeTail(hiddenChunks.join('')) }) const alt = snapshot.alternateScreen - const knownSerializeWrapBug = bufferHasSerializeHostileWrappedRow(source.terminal) // The pending escape tail must be the FINAL replay write (mirrors applyMainBufferSnapshot); a later ESC would abort the dangling sequence. const preamble = alt && snapshot.scrollbackAnsi !== undefined @@ -168,7 +164,6 @@ async function revealFromSnapshot( } const result = await readScreen(restored) result.forcedFreshRestore = forcedFreshRestore - result.knownSerializeWrapBug = knownSerializeWrapBug return result } finally { source.terminal.dispose() @@ -388,7 +383,6 @@ describe('hidden reveal seq-reconciliation fuzz', () => { let statsCompared = 0 let statsSkippedScrolled = 0 let statsForcedFresh = 0 - let statsKnownWrapBug = 0 for (let i = 0; i < ITERATIONS; i++) { const seed = FIXED_SEED ?? 1 + i const scenario = buildScenario(seed) @@ -403,10 +397,6 @@ describe('hidden reveal seq-reconciliation fuzz', () => { statsForcedFresh += 1 continue } - if (reveal.knownSerializeWrapBug || reference.knownSerializeWrapBug) { - statsKnownWrapBug += 1 - continue - } if (reveal.alternate !== reference.alternate) { expect.fail( formatFailure(scenario, 'alt-screen-state', reference.alternate, reveal.alternate) @@ -456,8 +446,6 @@ describe('hidden reveal seq-reconciliation fuzz', () => { if (FIXED_SEED === null) { expect(statsCompared).toBeGreaterThan(ITERATIONS * 0.2) } - expect( - statsCompared + statsSkippedScrolled + statsForcedFresh + statsKnownWrapBug - ).toBeLessThanOrEqual(ITERATIONS) + expect(statsCompared + statsSkippedScrolled + statsForcedFresh).toBeLessThanOrEqual(ITERATIONS) }, 120_000) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts index 7d28a7d43f6..823ce3dd867 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -151,7 +151,7 @@ describe('canParkTerminalWorktreeRenderers', () => { ).toBe(false) }) - it('keeps a previously mounted v19 terminal eligible for ordinary parking', async () => { + it('defers preserved-daemon snapshot authority to the watcher coverage gate', async () => { const legacyPtyId = 'repo::/worktree@@session-1' clearTerminalProviderSnapshotCapabilities() await synchronizeTerminalProviderSnapshotCapabilities([legacyPtyId], async (ids) => diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts index 11a3bb51656..f288521ae8a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { TERMINAL_WORKTREE_PARK_DELAY_MS } from './terminal-hidden-view-parking' +import { + clearTerminalProviderSnapshotCapabilities, + synchronizeTerminalProviderSnapshotCapabilities +} from '../terminal/terminal-provider-snapshot-capability' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, hasPendingRetentionSpawnWork, @@ -44,18 +48,36 @@ describe('hasPendingRetentionSpawnWork', () => { describe('isEvictionExemptTerminalPty', () => { const worktreeId = 'repo::/worktree' + const currentPtyId = `${worktreeId}@@session-1` + + beforeEach(async () => { + clearTerminalProviderSnapshotCapabilities() + await synchronizeTerminalProviderSnapshotCapabilities([currentPtyId], async () => [ + { id: currentPtyId, authoritative: true } + ]) + }) + afterEach(() => clearTerminalProviderSnapshotCapabilities()) it('exempts only live local ptys a remount could not reattach', () => { expect(isEvictionExemptTerminalPty('pty-local-detached', worktreeId)).toBe(true) expect(isEvictionExemptTerminalPty('other::wt@@session-1', worktreeId)).toBe(true) }) - it('never exempts snapshot-backed, SSH, remote-runtime, or unbound ptys', () => { - expect(isEvictionExemptTerminalPty(`${worktreeId}@@session-1`, worktreeId)).toBe(false) + it('never exempts authoritative, SSH, remote-runtime, or unbound ptys', () => { + expect(isEvictionExemptTerminalPty(currentPtyId, worktreeId)).toBe(false) expect(isEvictionExemptTerminalPty('ssh:conn-1@@pty-1', worktreeId)).toBe(false) expect(isEvictionExemptTerminalPty('remote:env-1@@t-1', worktreeId)).toBe(false) expect(isEvictionExemptTerminalPty(null, worktreeId)).toBe(false) }) + + it('exempts a preserved daemon without an authoritative snapshot', async () => { + clearTerminalProviderSnapshotCapabilities() + await synchronizeTerminalProviderSnapshotCapabilities([currentPtyId], async () => [ + { id: currentPtyId, authoritative: false } + ]) + + expect(isEvictionExemptTerminalPty(currentPtyId, worktreeId)).toBe(true) + }) }) describe('selectRetentionForceParkedTerminalWorktrees', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 9cfc4e3742f..161d3376043 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -1,5 +1,6 @@ import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { terminalProviderHasAuthoritativeSnapshot } from '../terminal/terminal-provider-snapshot-capability' import { TERMINAL_WORKTREE_COLD_PARK_DELAY_MS, isSnapshotBackedTerminalPty, @@ -41,10 +42,9 @@ export function hasPendingRetentionSpawnWork( return Boolean(tab.pendingActivationSpawn && (!tab.ptyId || !isRemoteRuntimePtyId(tab.ptyId))) } -// Why: an eviction-exempt pty is a live local one a remount could not reattach -// (daemon-fail-open separator-less ids, ptys minted under another worktree) — a -// fresh spawn would orphan the live shell. Its TAB keeps its mounted pane when -// the worktree force-parks (per-tab exclusion, mirroring Activity portals). +// Why: an eviction-exempt pty is a live local one a remount cannot restore +// faithfully (daemon-fail-open/foreign ids or a preserved legacy daemon). Its +// TAB keeps its mounted pane when the worktree force-parks. // Per-PTY, not per-tab: the coverage veto that makes a worktree a retention // candidate walks every split pane, so the exemption must too (see // isEvictionExemptTerminalTab). @@ -55,7 +55,10 @@ export function isEvictionExemptTerminalPty( if (!ptyId || isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) { return false } - return !isSnapshotBackedTerminalPty(ptyId, worktreeId) + return ( + !isSnapshotBackedTerminalPty(ptyId, worktreeId) || + !terminalProviderHasAuthoritativeSnapshot(ptyId) + ) } export type TerminalWorktreeRetentionCandidate = { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts new file mode 100644 index 00000000000..7263caf780a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { fallbackParkedPaneCandidates } from './terminal-parked-tab-watchers' + +const TAB_ID = 'tab-1' +const PTY_ID = 'repo::/worktree@@session-1' +const SECOND_PTY_ID = 'repo::/worktree@@session-2' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +describe('fallbackParkedPaneCandidates', () => { + it('returns nothing without a layout snapshot', () => { + expect( + fallbackParkedPaneCandidates( + { id: TAB_ID, ptyId: PTY_ID }, + { terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} } + ) + ).toEqual([]) + }) + + it('reuses the single runtime-title slot for a single-pane tab', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } } + } as never) + ).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }]) + }) + + // Why: a tab that never mounted a pane persists a rootless layout. Walking + // only `root` yielded zero candidates, so watcher coverage refused it and a + // manual park could never succeed for a workspace the user had not visited. + it('resolves the single leaf of a rootless layout', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { + root: null, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + }, + runtimePaneTitlesByTabId: {} + } as never) + ).toEqual([{ ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: true }]) + }) + + it('returns nothing for a rootless layout with no resolvable leaf', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { root: null, activeLeafId: null, expandedLeafId: null } + }, + runtimePaneTitlesByTabId: {} + } as never) + ).toEqual([]) + }) + + it('maps split leaves to layout PTYs with collision-free negative pane ids', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } } + } as never) + ).toEqual([ + { ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false }, + { ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true } + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index 21c292751ce..2e9b3a52ef2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -101,12 +101,15 @@ import { isEvictionExemptTerminalTab, selectEvictionExemptTerminalTabIds } from './terminal-eviction-exempt-tabs' +import { + clearTerminalProviderSnapshotCapabilities, + synchronizeTerminalProviderSnapshotCapabilities +} from '../terminal/terminal-provider-snapshot-capability' import { canWatcherCoverParkedTerminalTab, captureParkedTerminalPaneCandidates, disposeParkedTerminalWatchersForPtyIds, disposeParkedTerminalWatchersForWorktree, - fallbackParkedPaneCandidates, getParkedTerminalWatcherTabIds, pruneParkedTerminalWatchers, shouldDeferParkedPtyExitTabClose, @@ -140,7 +143,7 @@ function syncParked(args?: { } describe('terminal-parked-tab-watchers', () => { - beforeEach(() => { + beforeEach(async () => { mockStoreState = { tabsByWorktree: {}, terminalLayoutsByTabId: {}, @@ -154,6 +157,10 @@ describe('terminal-parked-tab-watchers', () => { updateTabTitle: vi.fn() } ;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } } + clearTerminalProviderSnapshotCapabilities() + await synchronizeTerminalProviderSnapshotCapabilities([PTY_ID, SECOND_PTY_ID], async (ids) => + ids.map((id) => ({ id, authoritative: true })) + ) }) afterEach(() => { @@ -163,6 +170,7 @@ describe('terminal-parked-tab-watchers', () => { startedWatchers.length = 0 exitSubscriptions.length = 0 vi.clearAllMocks() + clearTerminalProviderSnapshotCapabilities() ;(globalThis as { window?: unknown }).window = originalWindow }) @@ -721,7 +729,7 @@ describe('terminal-parked-tab-watchers', () => { ) }) - it('lets cold activation add stricter eligibility without changing ordinary parking', () => { + it('lets cold activation add stricter eligibility for a provider-capable local PTY', () => { capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) const providerCanSnapshotWithoutRenderer = vi.fn(() => false) @@ -738,6 +746,18 @@ describe('terminal-parked-tab-watchers', () => { expect(providerCanSnapshotWithoutRenderer).toHaveBeenCalledWith(PTY_ID) }) + it('rejects ordinary parking for a preserved daemon with a lossy snapshot', async () => { + clearTerminalProviderSnapshotCapabilities() + await synchronizeTerminalProviderSnapshotCapabilities([PTY_ID], async () => [ + { id: PTY_ID, authoritative: false } + ]) + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + it('rejects a capture containing a legacy non-UUID leaf id', () => { capturePanes([ { ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true }, @@ -860,6 +880,18 @@ describe('terminal-parked-tab-watchers', () => { expect(isEvictionExemptTerminalTab({ id: TAB_ID, ptyId: PTY_ID }, WORKTREE_ID)).toBe(false) }) + it('exempts a preserved daemon whose snapshot is not authoritative', async () => { + clearTerminalProviderSnapshotCapabilities() + await synchronizeTerminalProviderSnapshotCapabilities([PTY_ID], async () => [ + { id: PTY_ID, authoritative: false } + ]) + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + + const tab = { id: TAB_ID, ptyId: PTY_ID } + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, tab)).toBe(false) + expect(isEvictionExemptTerminalTab(tab, WORKTREE_ID)).toBe(true) + }) + it('exempts on tab.ptyId alone when no panes resolve', () => { expect( isEvictionExemptTerminalTab({ id: TAB_ID, ptyId: 'pty-local-detached' }, WORKTREE_ID) @@ -886,78 +918,3 @@ describe('terminal-parked-tab-watchers', () => { }) }) }) - -describe('fallbackParkedPaneCandidates', () => { - it('returns nothing without a layout snapshot', () => { - expect( - fallbackParkedPaneCandidates( - { id: TAB_ID, ptyId: PTY_ID }, - { terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} } - ) - ).toEqual([]) - }) - - it('reuses the single runtime-title slot for a single-pane tab', () => { - expect( - fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { - terminalLayoutsByTabId: { - [TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null } - }, - runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } } - } as never) - ).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }]) - }) - - // Why: a tab that never mounted a pane persists a rootless layout. Walking - // only `root` yielded zero candidates, so watcher coverage refused it and a - // manual park could never succeed for a workspace the user had not visited. - it('resolves the single leaf of a rootless layout', () => { - expect( - fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { - terminalLayoutsByTabId: { - [TAB_ID]: { - root: null, - activeLeafId: LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } - } - }, - runtimePaneTitlesByTabId: {} - } as never) - ).toEqual([{ ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: true }]) - }) - - it('returns nothing for a rootless layout with no resolvable leaf', () => { - expect( - fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { - terminalLayoutsByTabId: { - [TAB_ID]: { root: null, activeLeafId: null, expandedLeafId: null } - }, - runtimePaneTitlesByTabId: {} - } as never) - ).toEqual([]) - }) - - it('maps split leaves to layout PTYs with collision-free negative pane ids', () => { - expect( - fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { - terminalLayoutsByTabId: { - [TAB_ID]: { - root: { - type: 'split', - direction: 'row', - first: { type: 'leaf', leafId: LEAF_ID }, - second: { type: 'leaf', leafId: SECOND_LEAF_ID } - }, - activeLeafId: SECOND_LEAF_ID, - ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } - } - }, - runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } } - } as never) - ).toEqual([ - { ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false }, - { ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true } - ]) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 59d1b90db3e..42de06cac37 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -7,7 +7,10 @@ */ import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { useAppStore } from '@/store' +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' +import { terminalProviderHasAuthoritativeSnapshot } from '../terminal/terminal-provider-snapshot-capability' import { isParkRestorableTerminalPty, selectPairedRuntimeParkingEnvironmentIds, @@ -46,7 +49,10 @@ export { export type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation' export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean -const allowSnapshotBackedPty = (): boolean => true +const allowOrdinaryParkRestore = (ptyId: string): boolean => + isRemoteRuntimePtyId(ptyId) || + parseAppSshPtyId(ptyId) !== null || + terminalProviderHasAuthoritativeSnapshot(ptyId) // Why: fact-mode watchers work for any pty whose bytes transit local main — // SSH included — so watcher coverage follows the park-restore policy, not the @@ -74,8 +80,9 @@ function parkRestorePolicyFromState(state: { export function canWatcherCoverParkedTerminalTab( worktreeId: string, tab: ParkableTerminalTabModel, - // Why: cold activation needs stronger snapshot support (view never mounted); ordinary parking can reattach a mounted view. - isPtyEligible: ParkedTerminalPtyEligibility = allowSnapshotBackedPty + // Why: paired and SSH restore through their own policy; local parking must not + // unmount a pane whose preserved daemon can only return a lossy snapshot. + isPtyEligible: ParkedTerminalPtyEligibility = allowOrdinaryParkRestore ): boolean { const state = useAppStore.getState() const panes = resolveParkedTerminalPaneCandidates(tab, state) diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts index 94cf42f1299..b1c727f9e4e 100644 --- a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts +++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearTerminalProviderSnapshotCapabilities, collectTerminalProviderSnapshotPtyIds, + refreshTerminalProviderSnapshotCapabilities, synchronizeTerminalProviderSnapshotCapabilities, terminalProviderHasAuthoritativeSnapshot } from './terminal-provider-snapshot-capability' @@ -44,6 +45,18 @@ describe('terminal provider snapshot capabilities', () => { expect(terminalProviderHasAuthoritativeSnapshot('legacy')).toBe(false) }) + it('refreshes a pre-provider false after startup services become ready', async () => { + await synchronizeTerminalProviderSnapshotCapabilities(['restored-pty'], async () => [ + { id: 'restored-pty', authoritative: false } + ]) + const readyResolve = vi.fn(async () => [{ id: 'restored-pty', authoritative: true }]) + + await refreshTerminalProviderSnapshotCapabilities(['restored-pty'], readyResolve) + + expect(readyResolve).toHaveBeenCalledWith(['restored-pty']) + expect(terminalProviderHasAuthoritativeSnapshot('restored-pty')).toBe(true) + }) + it('caches resolved PTYs and prunes closed ones', async () => { const resolve = vi.fn(async (ids: string[]) => ids.map((id) => ({ id, authoritative: true as boolean | null })) diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts index b3fee0d3b96..3c4e0b7b61b 100644 --- a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts +++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts @@ -178,6 +178,20 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( return unknownCapabilityRetryDelayMs(observedAtMs === undefined ? Date.now() : nowMs) } +export async function refreshTerminalProviderSnapshotCapabilities( + livePtyIds: readonly string[], + resolveCapabilities?: SnapshotCapabilityResolver +): Promise { + lastSynchronizedLivePtyIds = null + for (const id of livePtyIds) { + authoritativeSnapshotByPtyId.delete(id) + unknownCapabilityRetryAtByPtyId.delete(id) + unknownCapabilityAttemptsByPtyId.delete(id) + } + refreshEarliestUnknownCapabilityRetry() + return synchronizeTerminalProviderSnapshotCapabilities(livePtyIds, resolveCapabilities) +} + export function startTerminalProviderSnapshotCapabilitySynchronization( livePtyIds: readonly string[] ): () => void { diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index 714d962a1ab..d7b46db0959 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 31, + "daemonProtocolVersion": 32, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31 + 27, 28, 29, 30, 31, 32 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index f65912cc450..07bbacd3d3a 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 31, + daemonProtocolVersion: 32, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31 + 27, 28, 29, 30, 31, 32 ] } as const diff --git a/src/shared/terminal-restore-parity-fixture.ts b/src/shared/terminal-restore-parity-fixture.ts index 5444b701ec2..884eadb14f1 100644 --- a/src/shared/terminal-restore-parity-fixture.ts +++ b/src/shared/terminal-restore-parity-fixture.ts @@ -60,6 +60,13 @@ export function visibleRows(terminal: Terminal): string[] { return rows } +export function visibleRowWraps(terminal: Terminal): boolean[] { + const buffer = terminal.buffer.active + return Array.from({ length: terminal.rows }, (_, y) => + Boolean(buffer.getLine(buffer.baseY + y)?.isWrapped) + ) +} + // xterm attribute color modes (Attributes CM_* in xterm's buffer model). const COLOR_MODE_P16 = 16777216 const COLOR_MODE_P256 = 33554432 @@ -75,105 +82,69 @@ function canonicalColorMode(mode: number, color: number): number { /** Per-cell descriptor rows so SGR runs that shift cells are caught even * when the text matches. Encodes only VISUALLY EFFECTIVE state: * - glyph cells: char, width, fg, bg, all attribute flags; - * - blank cells (null cells and spaces render identically): width, bg, - * underline/strikethrough (drawn across blanks), and fg only when inverse - * swaps it into the cell background. SerializeAddon legitimately skips - * null cells with cursor motion, dropping their invisible fg/bold/italic - * state, and may materialize a skipped run as plain spaces — neither can - * be seen, so neither may fail the garble gate. + * - blank cells: width, bg, and fg only when inverse swaps it into the cell + * background. Literal spaces retain underline/strikethrough/overline, + * while Orca's WebGL glyph renderer skips decorations on null cells. + * SerializeAddon may materialize a skipped null run as plain spaces, so + * invisible fg/bold/italic state cannot fail the garble gate. * Trailing default blanks are trimmed: the serializer does not re-emit * pristine cells past the last written column. */ -export function visibleRowStyles(terminal: Terminal): string[] { - const buffer = terminal.buffer.active - const out: string[] = [] - for (let y = 0; y < terminal.rows; y++) { - const line = buffer.getLine(buffer.baseY + y) - const cells: string[] = [] - for (let x = 0; line && x < line.length; x++) { - const cell = line.getCell(x) - if (!cell) { - continue - } - const chars = cell.getChars() - const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor()) - const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor()) - if (chars === '' || chars === ' ') { - const blankFlags = [cell.isUnderline(), cell.isStrikethrough()] - .map((flag) => (flag ? '1' : '0')) - .join('') - const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : '' - cells.push( - `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blankFlags}${inverseFg}` - ) - continue - } - const flags = [ - cell.isBold(), - cell.isDim(), - cell.isItalic(), - cell.isUnderline(), - cell.isInverse(), - cell.isStrikethrough() +type TerminalBufferLine = ReturnType + +function effectiveRowStyles(line: TerminalBufferLine): string { + const cells: string[] = [] + for (let x = 0; line && x < line.length; x++) { + const cell = line.getCell(x) + if (!cell) { + continue + } + const chars = cell.getChars() + const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor()) + const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor()) + if (chars === '' || chars === ' ') { + const blankFlags = [ + chars === ' ' && cell.isUnderline(), + chars === ' ' && cell.isStrikethrough(), + chars === ' ' && cell.isOverline() ] .map((flag) => (flag ? '1' : '0')) .join('') - cells.push( - `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${flags}` - ) + const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : '' + cells.push(`▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blankFlags}${inverseFg}`) + continue } - const defaultBlank = `▯·w1·b0:-1·00` - while (cells.length > 0 && cells.at(-1) === defaultBlank) { - cells.pop() - } - out.push(cells.join('|')) + const flags = [ + cell.isBold(), + cell.isDim(), + cell.isItalic(), + cell.isUnderline(), + cell.isInverse(), + cell.isStrikethrough() + ] + .map((flag) => (flag ? '1' : '0')) + .join('') + cells.push( + `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${flags}` + ) } - return out + const defaultBlank = `▯·w1·b0:-1·000` + while (cells.length > 0 && cells.at(-1) === defaultBlank) { + cells.pop() + } + return cells.join('|') +} + +export function visibleRowStyles(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + return Array.from({ length: terminal.rows }, (_, y) => + effectiveRowStyles(buffer.getLine(buffer.baseY + y)) + ) } export function cursorPosition(terminal: Terminal): { x: number; y: number } { return { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY } } -/** KNOWN UPSTREAM BUG predicate (@xterm/addon-serialize 0.15.0-beta.287): - * null cells touching a soft-wrap boundary do not round-trip. Two confirmed - * variants (see the skipped repros in headless-emulator-fidelity.fuzz.test.ts): - * - V1 (cell loss): a wrapped continuation row starting with a NULL cell - * (only erasure creates those — typed spaces have chars ' ') passes the - * addon's wrap-validity ternary (SerializeAddon.ts ~L214 binds as - * `(chars && isDoubleWidth) ? ...`), so the blank is skipped with CUF — - * which clamps at the right margin instead of crossing the wrap boundary, - * overwriting the previous row's last cell and shifting the tail left. - * - V2 (filler artifact): a wrapped pair whose SOURCE row is entirely null - * takes the forced-wrap "magic" path, whose cleanup emits `ESC[0C`; CSI - * param 0 means 1, so the erase lands one cell right and the first filler - * '-' stays visible on the restored screen. - * The fuzz suites use this predicate to tolerate (and count) exactly these - * divergences without masking unknown ones. */ -export function bufferHasSerializeHostileWrappedRow(terminal: Terminal): boolean { - const buffer = terminal.buffer.active - for (let y = 1; y < buffer.length; y++) { - const line = buffer.getLine(y) - if (!line?.isWrapped) { - continue - } - if (line.getCell(0)?.getChars() === '') { - return true - } - const previous = buffer.getLine(y - 1) - let previousIsAllNull = previous !== undefined - for (let x = 0; previous && x < previous.length; x++) { - if (previous.getCell(x)?.getChars() !== '') { - previousIsAllNull = false - break - } - } - if (previousIsAllNull) { - return true - } - } - return false -} - /** Full normal-buffer text with trailing blank rows trimmed (SerializeAddon * restores content rows; both sides may differ only in trailing blanks). */ export function normalBufferRowsTrimmed(terminal: Terminal): string[] { @@ -188,6 +159,17 @@ export function normalBufferRowsTrimmed(terminal: Terminal): string[] { return rows } +export function normalBufferStylesTrimmed(terminal: Terminal): string[] { + const buffer = terminal.buffer.normal + const rows = Array.from({ length: buffer.length }, (_, y) => + effectiveRowStyles(buffer.getLine(y)) + ) + while (rows.length > 0 && rows.at(-1) === '') { + rows.pop() + } + return rows +} + // Mirror of applyMainBufferSnapshot's clear preamble (pty-connection.ts): // normal-buffer restores wipe screen+scrollback+home; alt-screen restores // clear only the alt screen so the normal buffer's scrollback survives. From a7ed5a45c2d7a9c6af777c9e1a3928d877e845ea Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:05:16 +0900 Subject: [PATCH 021/101] fix(mobile): render Mermaid diagrams in MobileMarkdown (#11185) * fix(mobile): render Mermaid diagrams in MobileMarkdown (#11141) Co-Authored-By: Grok Companion * fix(mobile): keep streaming mermaid fences as raw code until the fence closes * perf(mobile): memoize MermaidDiagram and add a CDN load watchdog * fix(mobile): escape mermaid source before embedding in WebView script JSON.stringify leaves , &, and U+2028/U+2029 raw, so a diagram source containing broke out of the inline script and ran arbitrary WebView JS. Diagram source is untrusted (agent output, PR/chat content), and this component now renders from chat and markdown preview, not just the PR sidebar. Escape those chars to \uXXXX; the literal still parses back to the exact source. Adds an adversarial buildHtml test. * fix(mobile): embed the mermaid engine instead of fetching it from a CDN The diagram WebView loaded mermaid from jsdelivr at runtime: offline and constrained-network renders always fell back, the stalled-load watchdog existed only to paper over that, and an unpinned floating-major CDN script with no integrity check ran inside the WebView. Embed the lockfile-pinned package's prebuilt bundle via a postinstall generator (same mechanism as the terminal WebView engine) so the document loads nothing external; the watchdog is removed as obsolete and a no-external-URL gate pins it. * chore(deps): align mermaid at 11.16.0 across desktop and mobile Desktop floated ^11.15.0 while the mobile embedded engine resolved 11.16.0. Raise the desktop floor so both lockfiles resolve the same version, and pin mobile exact: the generated WebView engine embeds the package bytes, so an implicit range bump would silently change what ships. * fix(mobile): block Mermaid diagram network requests Mermaid image-node URLs can initiate subresource requests even with the engine embedded. Keep the WebView offline by restricting resource types through its document CSP. * style(mobile): format Mermaid routing test * fix(mobile): use stable keys for Mermaid diagrams * fix(mobile): keep duplicate Mermaid keys distinct Combine each diagram source with its sibling occurrence so identical diagrams remain unique while source edits still remount the WebView and later streaming prose does not. * fix(mobile): keep Mermaid transitive within release-age policy --------- Co-authored-by: Grok Companion Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- mobile/.gitignore | 1 + mobile/package.json | 3 +- mobile/pnpm-lock.yaml | 792 ++++++++++++++++++ .../scripts/build-mermaid-webview-engine.mjs | 42 + mobile/src/components/MobileMarkdown.test.ts | 32 + mobile/src/components/MobileMarkdown.tsx | 19 + .../mobile-markdown-mermaid-routing.test.ts | 80 ++ .../src/components/mobile-markdown-parser.ts | 8 +- .../src/components/mobile-mermaid-language.ts | 4 + .../components/pr-sidebar/MermaidDiagram.tsx | 43 +- .../pr-sidebar/mermaid-diagram-html.test.ts | 60 ++ package.json | 2 +- pnpm-lock.yaml | 18 +- 13 files changed, 1077 insertions(+), 27 deletions(-) create mode 100644 mobile/scripts/build-mermaid-webview-engine.mjs create mode 100644 mobile/src/components/mobile-markdown-mermaid-routing.test.ts create mode 100644 mobile/src/components/mobile-mermaid-language.ts create mode 100644 mobile/src/components/pr-sidebar/mermaid-diagram-html.test.ts diff --git a/mobile/.gitignore b/mobile/.gitignore index 449fa3f2439..0428cbb65a9 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -1,5 +1,6 @@ node_modules/ src/terminal/terminal-webview-engine.generated.ts +src/components/pr-sidebar/mermaid-webview-engine.generated.ts .expo/ dist/ /android/ diff --git a/mobile/package.json b/mobile/package.json index 1041b422a34..44feca5f4c5 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -7,7 +7,7 @@ "start": "node scripts/start-expo.mjs", "android": "expo run:android", "ios": "expo run:ios", - "postinstall": "node scripts/build-terminal-webview-engine.mjs", + "postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs", "test": "vitest run", "typecheck": "tsc --noEmit", "lint": "oxlint", @@ -48,6 +48,7 @@ "expo-status-bar": "^55.0.6", "lowlight": "^3.3.0", "lucide-react-native": "^1.14.0", + "mermaid": "11.16.0", "react": "^19.2.6", "react-dom": "19.2.6", "react-native": "^0.83.9", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 9788413f925..5530acb4da1 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: lucide-react-native: specifier: ^1.14.0 version: 1.14.0(react-native-svg@15.15.4(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + mermaid: + specifier: 11.16.0 + version: 11.16.0 react: specifier: ^19.2.6 version: 19.2.6 @@ -195,6 +198,9 @@ importers: packages: + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@babel/cli@7.28.6': resolution: {integrity: sha512-6EUNcuBbNkj08Oj4gAZ+BUU8yLCgKzgVX4gaTh09Ya2C8ICM4P+G30g4m3akRxSYAp3A/gnWchrNst7px4/nUQ==} engines: {node: '>=6.9.0'} @@ -1178,6 +1184,12 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} @@ -1768,6 +1780,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + '@isaacs/ttlcache@1.4.1': resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} engines: {node: '>=12'} @@ -1881,6 +1899,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -2680,6 +2701,99 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2689,6 +2803,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -2741,6 +2858,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2818,6 +2938,9 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitest/expect@4.1.9': resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} @@ -3332,6 +3455,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -3353,6 +3480,12 @@ packages: core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3392,6 +3525,162 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-urls@3.0.2: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} @@ -3408,6 +3697,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3470,6 +3762,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3526,6 +3821,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -3611,6 +3909,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + esbuild@0.25.4: resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} engines: {node: '>=18'} @@ -4304,6 +4605,9 @@ packages: resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==} engines: {node: '>=20.0.0'} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -4436,6 +4740,9 @@ packages: engines: {node: '>=8'} hasBin: true + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -4458,6 +4765,13 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -4855,9 +5169,16 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -4866,6 +5187,12 @@ packages: resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} hasBin: true + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -4958,6 +5285,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -5018,6 +5348,11 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -5041,6 +5376,9 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + metro-babel-transformer@0.83.7: resolution: {integrity: sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==} engines: {node: '>=20.19.4'} @@ -5418,6 +5756,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5437,6 +5778,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5490,6 +5834,12 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5798,11 +6148,20 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.1.3: resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -6103,6 +6462,9 @@ packages: styleq@0.1.3: resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -6214,6 +6576,10 @@ packages: peerDependencies: typescript: '>=4.0.0' + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + ts-jest@29.0.5: resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6694,6 +7060,11 @@ packages: snapshots: + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.1.2 + '@babel/cli@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -7935,6 +8306,10 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + '@egjs/hammerjs@2.0.17': dependencies: '@types/hammerjs': 2.0.46 @@ -8637,6 +9012,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.4': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@isaacs/ttlcache@1.4.1': {} '@istanbuljs/load-nyc-config@1.1.0': @@ -8847,6 +9230,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -9603,12 +9990,131 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/deep-eql@4.0.2': {} '@types/emscripten@1.41.5': {} '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/graceful-fs@4.1.9': dependencies: '@types/node': 26.1.1 @@ -9677,6 +10183,9 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} '@types/whatwg-mimetype@3.0.2': {} @@ -9784,6 +10293,11 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -10407,6 +10921,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -10440,6 +10956,14 @@ snapshots: dependencies: browserslist: 4.28.2 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + create-jest@29.7.0(@types/node@26.1.1): dependencies: '@jest/types': 29.6.3 @@ -10496,6 +11020,190 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + data-urls@3.0.2: dependencies: abab: 2.0.6 @@ -10520,6 +11228,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.21: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -10560,6 +11270,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -10602,6 +11316,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -10748,6 +11466,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.50.0: {} + esbuild@0.25.4: optionalDependencies: '@esbuild/aix-ppc64': 0.25.4 @@ -11653,6 +12373,8 @@ snapshots: - bufferutil - utf-8-validate + hachure-fill@0.5.2: {} + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -11781,6 +12503,8 @@ snapshots: pkg-dir: 4.2.0 resolve-cwd: 3.0.0 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -11802,6 +12526,10 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + internmap@1.0.1: {} + + internmap@2.0.3: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -12446,14 +13174,24 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + kleur@3.0.3: {} lan-network@0.2.1: {} + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + leven@3.1.0: {} levn@0.4.1: @@ -12527,6 +13265,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.18.1: {} + lodash.debounce@4.0.8: {} lodash.memoize@4.1.2: {} @@ -12584,6 +13324,8 @@ snapshots: dependencies: tmpl: 1.0.5 + marked@16.4.2: {} + marky@1.3.0: {} math-intrinsics@1.1.0: {} @@ -12600,6 +13342,30 @@ snapshots: merge-stream@2.0.0: {} + mermaid@11.16.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.4 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.12 + es-toolkit: 1.50.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 11.1.1 + metro-babel-transformer@0.83.7: dependencies: '@babel/core': 7.29.7 @@ -13214,6 +13980,8 @@ snapshots: p-try@2.2.0: {} + package-manager-detector@1.8.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -13235,6 +14003,8 @@ snapshots: parseurl@1.3.3: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -13272,6 +14042,13 @@ snapshots: pngjs@3.4.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + possible-typed-array-names@1.1.0: {} postcss-value-parser@4.2.0: {} @@ -13646,6 +14423,8 @@ snapshots: dependencies: glob: 7.2.3 + robust-predicates@3.0.3: {} + rolldown@1.1.3: dependencies: '@oxc-project/types': 0.137.0 @@ -13667,6 +14446,15 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.3 '@rolldown/binding-win32-x64-msvc': 1.1.3 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -13983,6 +14771,8 @@ snapshots: styleq@0.1.3: {} + stylis@4.4.0: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -14084,6 +14874,8 @@ snapshots: picomatch: 4.0.4 typescript: 5.9.3 + ts-dedent@2.3.0: {} + ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.1))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 diff --git a/mobile/scripts/build-mermaid-webview-engine.mjs b/mobile/scripts/build-mermaid-webview-engine.mjs new file mode 100644 index 00000000000..bf53ae2f4b8 --- /dev/null +++ b/mobile/scripts/build-mermaid-webview-engine.mjs @@ -0,0 +1,42 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const scriptDir = import.meta.dirname +const mobileRoot = path.resolve(scriptDir, '..') +const outputPath = path.join( + mobileRoot, + 'src', + 'components', + 'pr-sidebar', + 'mermaid-webview-engine.generated.ts' +) + +// Why: unlike the terminal engine (esbuilt from source for old-WebView shims), +// mermaid's prebuilt UMD bundle is embedded verbatim — it is the exact artifact +// the diagram WebView previously fetched from a CDN, now pinned and +// integrity-checked through the lockfile and available offline. +async function main() { + const packageJsonPath = require.resolve('mermaid/package.json') + const [packageJson, bundleJs] = await Promise.all([ + readFile(packageJsonPath, 'utf8').then(JSON.parse), + readFile(path.join(path.dirname(packageJsonPath), 'dist', 'mermaid.min.js'), 'utf8') + ]) + + // A literal inside the bundle would close the inline script tag it + // is embedded in; \/ is identical to / in JS strings, regexes, and comments. + const inlineSafeJs = bundleJs.replace(/<\/script/gi, '<\\/script') + + const source = [ + '// Generated by scripts/build-mermaid-webview-engine.mjs.', + `// Package: mermaid@${packageJson.version} (dist/mermaid.min.js, embedded verbatim).`, + '// Do not edit by hand; regenerate via pnpm postinstall.', + `export const MERMAID_ENGINE_JS = ${JSON.stringify(inlineSafeJs)}`, + '' + ].join('\n') + + await writeFile(outputPath, source) +} + +await main() diff --git a/mobile/src/components/MobileMarkdown.test.ts b/mobile/src/components/MobileMarkdown.test.ts index 20a92663a49..5a59e749b20 100644 --- a/mobile/src/components/MobileMarkdown.test.ts +++ b/mobile/src/components/MobileMarkdown.test.ts @@ -1,8 +1,40 @@ import { describe, expect, it } from 'vitest' +import { isMobileMermaidLanguage } from './mobile-mermaid-language' import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' import { parseMobileMarkdown } from './mobile-markdown-parser' +describe('isMobileMermaidLanguage', () => { + it('matches mermaid case-insensitively after trim', () => { + expect(isMobileMermaidLanguage('mermaid')).toBe(true) + expect(isMobileMermaidLanguage('Mermaid')).toBe(true) + expect(isMobileMermaidLanguage(' MERMAID ')).toBe(true) + }) + + it('rejects non-mermaid languages and missing language', () => { + expect(isMobileMermaidLanguage(undefined)).toBe(false) + expect(isMobileMermaidLanguage('')).toBe(false) + expect(isMobileMermaidLanguage('ts')).toBe(false) + expect(isMobileMermaidLanguage('mermaidx')).toBe(false) + }) +}) + describe('parseMobileMarkdown', () => { + it('parses mermaid fences as code blocks with language mermaid', () => { + expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')).toEqual([ + { type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: true } + ]) + expect(isMobileMermaidLanguage('mermaid')).toBe(true) + }) + + it('marks an unterminated fence as not closed while it streams', () => { + expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B')).toEqual([ + { type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: false } + ]) + expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')[0]).toMatchObject({ + closed: true + }) + }) + it('parses GFM tables into table blocks', () => { expect(parseMobileMarkdown('| Name | State |\n| --- | --- |\n| Orca | Open |')).toEqual([ { diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 8e968f604ac..dc58f5e5d6a 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -7,7 +7,9 @@ import { isFilePathCodeSpan, normalizeFilePath } from './markdown-file-path-detection' +import { isMobileMermaidLanguage } from './mobile-mermaid-language' import { parseMobileMarkdown } from './mobile-markdown-parser' +import { MermaidDiagram } from './pr-sidebar/MermaidDiagram' type Props = { content?: string @@ -23,6 +25,8 @@ type Props = { const MAX_TABLE_ROWS = 40 const MAX_TABLE_COLUMNS = 8 +/** Prose base size — passed to MermaidDiagram fallback mono text. */ +const MERMAID_BASE = 13 function openMarkdownUrl(url: string): void { const trimmed = url.trim() @@ -153,6 +157,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile if (!text) { return fallback ? {fallback} : null } + const mermaidSourceOccurrences = new Map() return ( @@ -175,6 +180,20 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile ) } if (block.type === 'code') { + // Mermaid fences render as diagrams (WebView), not as raw code — same as PR sidebar. + // Unclosed fences are still streaming: mounting the WebView per tick would + // reload its document up to 20x/sec, so they stay raw code until terminated. + if (isMobileMermaidLanguage(block.language) && block.closed) { + const occurrence = mermaidSourceOccurrences.get(block.text) ?? 0 + mermaidSourceOccurrences.set(block.text, occurrence + 1) + return ( + + ) + } return ( {block.language ? {block.language} : null} diff --git a/mobile/src/components/mobile-markdown-mermaid-routing.test.ts b/mobile/src/components/mobile-markdown-mermaid-routing.test.ts new file mode 100644 index 00000000000..c89dfa00853 --- /dev/null +++ b/mobile/src/components/mobile-markdown-mermaid-routing.test.ts @@ -0,0 +1,80 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileMarkdown } from './MobileMarkdown' + +vi.mock('react-native', () => ({ + Linking: { openURL: vi.fn() }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) +vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' })) + +let renderer: ReactTestRenderer | undefined + +afterEach(() => { + renderer?.unmount() + renderer = undefined + vi.restoreAllMocks() +}) + +function render(content: string): ReactTestRenderer { + act(() => { + renderer = create(createElement(MobileMarkdown, { content })) + }) + return renderer! +} + +function mermaidCount(tree: ReactTestRenderer): number { + return tree.root.findAllByType('MermaidDiagram' as never).length +} + +function firstMermaid(tree: ReactTestRenderer) { + return tree.root.findByType('MermaidDiagram' as never) +} + +describe('MobileMarkdown mermaid routing', () => { + it('routes a closed mermaid fence to MermaidDiagram', () => { + const tree = render('```mermaid\ngraph TD; A-->B\n```') + expect(mermaidCount(tree)).toBe(1) + }) + + it('keeps a streaming (unterminated) mermaid fence as raw code', () => { + const tree = render('```mermaid\ngraph TD; A-->B') + expect(mermaidCount(tree)).toBe(0) + }) + + it('keeps identical sibling diagrams uniquely keyed', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const diagram = '```mermaid\ngraph TD; A-->B\n```' + const tree = render(`${diagram}\n\n${diagram}`) + const warnings = consoleError.mock.calls.flat().join(' ') + expect(mermaidCount(tree)).toBe(2) + expect(warnings).not.toContain('same key') + }) + + it('preserves a completed diagram while later prose streams', () => { + const diagram = '```mermaid\ngraph TD; A-->B\n```' + const tree = render(diagram) + const initial = firstMermaid(tree) + act(() => tree.update(createElement(MobileMarkdown, { content: `${diagram}\n\nNext` }))) + expect(firstMermaid(tree)).toBe(initial) + }) + + it('remounts a diagram when its source changes', () => { + const tree = render('```mermaid\ngraph TD; A-->B\n```') + const initial = firstMermaid(tree) + act(() => + tree.update(createElement(MobileMarkdown, { content: '```mermaid\ngraph TD; A-->C\n```' })) + ) + expect(firstMermaid(tree)).not.toBe(initial) + }) + + it('does not route other code fences to MermaidDiagram', () => { + const tree = render('```ts\nconst a = 1\n```') + expect(mermaidCount(tree)).toBe(0) + }) +}) diff --git a/mobile/src/components/mobile-markdown-parser.ts b/mobile/src/components/mobile-markdown-parser.ts index b6375f389c5..fa16b3b9310 100644 --- a/mobile/src/components/mobile-markdown-parser.ts +++ b/mobile/src/components/mobile-markdown-parser.ts @@ -2,7 +2,7 @@ export type MobileMarkdownBlock = | { type: 'paragraph'; text: string } | { type: 'heading'; level: number; text: string } | { type: 'quote'; text: string } - | { type: 'code'; text: string; language?: string } + | { type: 'code'; text: string; language?: string; closed: boolean } | { type: 'list'; ordered: boolean; items: Array<{ text: string; checked?: boolean }> } | { type: 'image'; alt: string; url: string } | { type: 'table'; headers: string[]; rows: string[][] } @@ -42,10 +42,12 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] { code.push(lines[index] ?? '') index += 1 } - if (index < lines.length) { + // closed=false means the fence is still streaming in (no terminator yet). + const closed = index < lines.length + if (closed) { index += 1 } - blocks.push({ type: 'code', text: code.join('\n'), language: fence[1] }) + blocks.push({ type: 'code', text: code.join('\n'), language: fence[1], closed }) continue } diff --git a/mobile/src/components/mobile-mermaid-language.ts b/mobile/src/components/mobile-mermaid-language.ts new file mode 100644 index 00000000000..8e7811ba167 --- /dev/null +++ b/mobile/src/components/mobile-mermaid-language.ts @@ -0,0 +1,4 @@ +/** True when a fenced code block language should render as a Mermaid diagram. */ +export function isMobileMermaidLanguage(language?: string): boolean { + return language?.trim().toLowerCase() === 'mermaid' +} diff --git a/mobile/src/components/pr-sidebar/MermaidDiagram.tsx b/mobile/src/components/pr-sidebar/MermaidDiagram.tsx index b4c4811c0d3..0df2aa210db 100644 --- a/mobile/src/components/pr-sidebar/MermaidDiagram.tsx +++ b/mobile/src/components/pr-sidebar/MermaidDiagram.tsx @@ -1,7 +1,8 @@ -import { useMemo, useState } from 'react' +import { memo, useMemo, useState } from 'react' import { ScrollView, StyleSheet, Text, View } from 'react-native' import { WebView } from 'react-native-webview' import { colors, radii, spacing, typography } from '../../theme/mobile-theme' +import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated' type Props = { source: string @@ -9,11 +10,15 @@ type Props = { } // Renders a ```mermaid fence as a diagram via a sandboxed WebView (mermaid has no -// native RN renderer). Mermaid is loaded from a CDN inside the WebView HTML, the -// SVG is themed dark to match the sidebar, and the WebView posts back its rendered -// height so we can size to content. On any failure (no network, parse error, -// render error) we fall back to the raw source in a labeled mono code box. -export function MermaidDiagram({ source, base }: Props) { +// native RN renderer). Mermaid ships inside the app as a generated bundle embedded +// in the WebView HTML — no network — the SVG is themed dark to match the sidebar, +// and the WebView posts back its rendered height so we can size to content. On any +// failure (parse error, render error) we fall back to the raw source in a labeled +// mono code box. +// memo: both props are primitives; without it every mounted diagram re-renders +// per frame during pinch-to-zoom (textScale updates), marshalling the full HTML +// string across the Fabric boundary each time. +export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: Props) { const [height, setHeight] = useState(0) const [failed, setFailed] = useState(false) const html = useMemo(() => buildHtml(source), [source]) @@ -58,7 +63,7 @@ export function MermaidDiagram({ source, base }: Props) { /> ) -} +}) function MermaidFallback({ source, base }: Props) { return ( @@ -73,21 +78,33 @@ function MermaidFallback({ source, base }: Props) { ) } -// Self-contained HTML: load mermaid from CDN, render the graph, post the body +// JSON.stringify escapes quotes and control chars but leaves `<`, `>`, `&`, and +// the U+2028/U+2029 line separators raw — so a source containing `` +// would close this inline +
@@ -114,7 +131,7 @@ function buildHtml(source: string): string { } }); mermaid.run({ querySelector: '.mermaid' }) - .then(reportHeight) + .then(function () { reportHeight(); }) .catch(function () { post('error'); }); } catch (e) { post('error'); diff --git a/mobile/src/components/pr-sidebar/mermaid-diagram-html.test.ts b/mobile/src/components/pr-sidebar/mermaid-diagram-html.test.ts new file mode 100644 index 00000000000..d66d82d9f53 --- /dev/null +++ b/mobile/src/components/pr-sidebar/mermaid-diagram-html.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildHtml } from './MermaidDiagram' +import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated' + +vi.mock('react-native', () => ({ + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) +vi.mock('react-native-webview', () => ({ WebView: 'WebView' })) + +// The diagram source is untrusted (agent output, PR/chat content). It is embedded +// inside an inline payload break out of the inline script', () => { + const payload = 'graph TD; A-->B' + const countClosers = (html: string) => (html.match(/<\/script>/gi) ?? []).length + // The payload's two must add zero raw closers over a benign render — + // they were neutralized to \u003c instead of closing our inline script. + const benign = countClosers(buildHtml('graph TD; A-->B')) + expect(countClosers(buildHtml(payload))).toBe(benign) + expect(buildHtml(payload)).toContain('\\u003c/script') + }) + + it('escapes the U+2028/U+2029 line separators that would break the JS literal', () => { + const payload = `a${String.fromCharCode(0x2028)}b${String.fromCharCode(0x2029)}c` + const html = buildHtml(payload) + expect(html).toContain('\\u2028') + expect(html).toContain('\\u2029') + expect(html.includes(String.fromCharCode(0x2028))).toBe(false) + expect(html.includes(String.fromCharCode(0x2029))).toBe(false) + }) + + it('still round-trips ordinary source to the exact original string', () => { + const payload = 'graph LR\n A["node & "] --> B' + const html = buildHtml(payload) + const match = html.match(/\.textContent = (".*?");\n {4}mermaid\.initialize/s) + expect(match).not.toBeNull() + expect(JSON.parse(match![1]!)).toBe(payload) + }) + + // Same gate as the terminal document: mermaid is embedded from the lockfile-pinned + // package, so the document itself must load nothing external (offline-safe, no CDN + // supply-chain exposure). The engine's internal URL literals (xmlns, docs links) + // are inert data, so the gate checks the document with the engine stripped out. + it('embeds the mermaid engine and loads no external resource', () => { + const html = buildHtml('graph TD; A-->B') + expect(html).toContain(MERMAID_ENGINE_JS) + expect(html.replace(MERMAID_ENGINE_JS, '')).not.toMatch(/\bhttps?:\/\//) + }) + + it('blocks external resources requested by diagram syntax', () => { + const html = buildHtml('flowchart LR\nA@{ img: "https://example.com/pixel.png" }') + const policy = html.match(/Content-Security-Policy" content="([^"]+)"/)?.[1] + expect(policy).toContain("default-src 'none'") + expect(policy).toContain('img-src data: blob:') + expect(policy).not.toMatch(/https?:/) + }) +}) diff --git a/package.json b/package.json index 3b7da4f9bdd..3a86ecfaf71 100644 --- a/package.json +++ b/package.json @@ -209,7 +209,7 @@ "lowlight": "^3.3.0", "lucide-react": "^0.577.0", "marked": "^17.0.1", - "mermaid": "^11.15.0", + "mermaid": "^11.16.0", "monaco-editor": "^0.55.1", "oxfmt": "^0.52.0", "oxlint": "^1.75.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3db5addebb..c4d53a979e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -283,8 +283,8 @@ importers: specifier: ^17.0.1 version: 17.0.6 mermaid: - specifier: ^11.15.0 - version: 11.15.0 + specifier: ^11.16.0 + version: 11.16.0 monaco-editor: specifier: ^0.55.1 version: 0.55.1 @@ -1041,8 +1041,8 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} - '@mermaid-js/parser@1.1.1': - resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} @@ -5211,8 +5211,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.15.0: - resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -7511,7 +7511,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@mermaid-js/parser@1.1.1': + '@mermaid-js/parser@1.2.0': dependencies: '@chevrotain/types': 11.1.2 @@ -11647,11 +11647,11 @@ snapshots: merge2@1.4.1: {} - mermaid@11.15.0: + mermaid@11.16.0: dependencies: '@braintree/sanitize-url': 7.1.2 '@iconify/utils': 3.1.1 - '@mermaid-js/parser': 1.1.1 + '@mermaid-js/parser': 1.2.0 '@types/d3': 7.4.3 '@upsetjs/venn.js': 2.0.0 cytoscape: 3.33.3 From 026ed921c1e0960db7020b770e46276c3441d8d5 Mon Sep 17 00:00:00 2001 From: Shahar Mor Date: Tue, 4 Aug 2026 06:25:46 +0300 Subject: [PATCH 022/101] fix(mobile): keep main-buffer TUI footer above the iOS keyboard (#9178) * fix(mobile): keep main-buffer TUI footer above the iOS keyboard The iOS keyboard-avoidance lift anchored on the terminal cursor row. Pi's TUI renders in the main screen buffer (not the alternate screen) with its footer/status rows below the input caret, so the altScreen full-lift branch was skipped and those rows stayed under the raised dock / keyboard. Anchor the lift on the bottom-most non-blank viewport row instead of just the cursor: the WebView now emits contentBottomRow, and the lift uses max(cursorY, contentBottomRow). This generalizes the alt-screen case, keeps short output at the top put, and matches prior behavior for a scrolled shell prompt. Extracted the lift into a pure, unit-tested function (terminal-keyboard-avoidance-lift.ts) and moved metrics parsing into a tested helper on the contract. * fix(mobile): preserve keyboard metrics through notification dispatch * fix(mobile): harden terminal keyboard metrics * fix(mobile): ignore unstyled terminal whitespace * fix(mobile): preserve decorated terminal whitespace --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../app/h/[hostId]/session/[worktreeId].tsx | 25 +- .../terminal-keyboard-avoidance-lift.test.ts | 122 ++++++++++ .../terminal-keyboard-avoidance-lift.ts | 29 +++ ...nal-keyboard-avoidance-metrics-injected.ts | 43 ++++ ...erminal-keyboard-avoidance-webview.test.ts | 215 ++++++++++++++++++ .../terminal-webview-contract.test.ts | 52 +++++ .../src/terminal/terminal-webview-contract.ts | 24 ++ mobile/src/terminal/terminal-webview-html.ts | 16 +- ...inal-webview-notification-dispatch.test.ts | 39 ++++ .../terminal-webview-notification-dispatch.ts | 13 +- .../terminal-webview-reflow-injected.ts | 1 + 11 files changed, 542 insertions(+), 37 deletions(-) create mode 100644 mobile/src/terminal/terminal-keyboard-avoidance-lift.test.ts create mode 100644 mobile/src/terminal/terminal-keyboard-avoidance-lift.ts create mode 100644 mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts create mode 100644 mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts create mode 100644 mobile/src/terminal/terminal-webview-contract.test.ts create mode 100644 mobile/src/terminal/terminal-webview-notification-dispatch.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 4f190c08d0f..4d1f5319636 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -107,6 +107,7 @@ import type { TerminalWebViewHandle } from '../../../../src/terminal/terminal-webview-contract' import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges' +import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift' import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit' import { getDefaultTerminalAccessoryBuiltInIds, @@ -3599,6 +3600,7 @@ export default function SessionScreen() { if ( current && current.cursorY === metrics.cursorY && + current.contentBottomRow === metrics.contentBottomRow && current.rows === metrics.rows && current.altScreen === metrics.altScreen ) { @@ -4280,24 +4282,11 @@ export default function SessionScreen() { ? Math.max(0, keyboardHeight - insets.bottom) : keyboardHeight : 0 - const activeTerminalKeyboardLift = (() => { - if (keyboardLift <= 0 || !activeHandle) { - return 0 - } - const metrics = terminalKeyboardMetrics.get(activeHandle) - if (!metrics || metrics.rows <= 0 || terminalFrameHeightRef.current <= 0) { - return keyboardLift - } - if (metrics.altScreen) { - return keyboardLift - } - const rowHeight = terminalFrameHeightRef.current / metrics.rows - const cursorBottom = (metrics.cursorY + 1) * rowHeight - const dockTop = terminalFrameHeightRef.current - keyboardLift - const margin = rowHeight - // Why: only move the terminal when the cursor would sit under the raised input dock; short top output stays put. - return Math.min(keyboardLift, Math.max(0, cursorBottom + margin - dockTop)) - })() + const activeTerminalKeyboardLift = computeActiveTerminalKeyboardLift({ + keyboardLift, + metrics: activeHandle ? terminalKeyboardMetrics.get(activeHandle) : undefined, + terminalFrameHeight: terminalFrameHeightRef.current + }) const toastAnimatedStyle = { opacity: toastOpacityRef.current, transform: [{ translateY: -keyboardLift }] diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-lift.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-lift.test.ts new file mode 100644 index 00000000000..4c11320db2e --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-avoidance-lift.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' + +import { computeActiveTerminalKeyboardLift } from './terminal-keyboard-avoidance-lift' +import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' +import type { TerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' + +const FRAME_HEIGHT = 800 +const ROWS = 40 +const KEYBOARD_LIFT = 300 + +function metrics( + overrides: Partial = {} +): TerminalKeyboardAvoidanceMetrics { + return { cursorY: 0, contentBottomRow: 0, rows: ROWS, altScreen: false, ...overrides } +} + +describe('computeActiveTerminalKeyboardLift', () => { + it('returns 0 when the keyboard is closed', () => { + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: 0, + metrics: metrics({ cursorY: 30, contentBottomRow: 34 }), + terminalFrameHeight: FRAME_HEIGHT + }) + ).toBe(0) + }) + + it('falls back to the full lift when metrics are missing', () => { + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: undefined, + terminalFrameHeight: FRAME_HEIGHT + }) + ).toBe(KEYBOARD_LIFT) + }) + + it('falls back to the full lift when rows or frame height are unmeasured', () => { + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ rows: 0 }), + terminalFrameHeight: FRAME_HEIGHT + }) + ).toBe(KEYBOARD_LIFT) + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics(), + terminalFrameHeight: 0 + }) + ).toBe(KEYBOARD_LIFT) + }) + + it('lifts fully for alt-screen TUIs', () => { + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ cursorY: 10, contentBottomRow: 10, altScreen: true }), + terminalFrameHeight: FRAME_HEIGHT + }) + ).toBe(KEYBOARD_LIFT) + }) + + it('clears a main-buffer footer while an old payload retains cursor-only behavior', () => { + const candidate = computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ cursorY: 30, contentBottomRow: 34 }), + terminalFrameHeight: FRAME_HEIGHT + }) + const oldPayload = parseTerminalKeyboardAvoidanceMetrics({ cursorY: 30, rows: ROWS }) + const cursorOnly = computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: oldPayload, + terminalFrameHeight: FRAME_HEIGHT + }) + expect({ candidate, cursorOnly }).toEqual({ candidate: 220, cursorOnly: 140 }) + }) + + it('keeps short output near the top put (no lift)', () => { + expect( + computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ cursorY: 2, contentBottomRow: 5 }), + terminalFrameHeight: FRAME_HEIGHT + }) + ).toBe(0) + }) + + it('matches cursor-clearing behavior for a scrolled shell (prompt at the bottom)', () => { + const lift = computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ cursorY: 38, contentBottomRow: 38 }), + terminalFrameHeight: FRAME_HEIGHT + }) + expect(lift).toBe(KEYBOARD_LIFT) + }) + + it('never exceeds the keyboard lift', () => { + const lift = computeActiveTerminalKeyboardLift({ + keyboardLift: KEYBOARD_LIFT, + metrics: metrics({ cursorY: 39, contentBottomRow: 39 }), + terminalFrameHeight: FRAME_HEIGHT + }) + expect(lift).toBeLessThanOrEqual(KEYBOARD_LIFT) + }) + + it('uses the platform-adjusted lift proportionally on iOS and Android', () => { + const tuiMetrics = metrics({ cursorY: 30, contentBottomRow: 34 }) + const android = computeActiveTerminalKeyboardLift({ + keyboardLift: 300, + metrics: tuiMetrics, + terminalFrameHeight: FRAME_HEIGHT + }) + const ios = computeActiveTerminalKeyboardLift({ + keyboardLift: 266, + metrics: tuiMetrics, + terminalFrameHeight: FRAME_HEIGHT + }) + expect({ android, ios }).toEqual({ android: 220, ios: 186 }) + }) +}) diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-lift.ts b/mobile/src/terminal/terminal-keyboard-avoidance-lift.ts new file mode 100644 index 00000000000..6ada640430d --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-avoidance-lift.ts @@ -0,0 +1,29 @@ +import type { TerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' + +type ActiveTerminalKeyboardLiftParams = { + keyboardLift: number + metrics: TerminalKeyboardAvoidanceMetrics | undefined + terminalFrameHeight: number +} + +export function computeActiveTerminalKeyboardLift( + params: ActiveTerminalKeyboardLiftParams +): number { + const { keyboardLift, metrics, terminalFrameHeight } = params + if (keyboardLift <= 0) { + return 0 + } + if (!metrics || metrics.rows <= 0 || terminalFrameHeight <= 0) { + return keyboardLift + } + if (metrics.altScreen) { + return keyboardLift + } + const rowHeight = terminalFrameHeight / metrics.rows + // Main-buffer TUI footer rows can sit below the caret. + const anchorRow = Math.max(metrics.cursorY, metrics.contentBottomRow) + const anchorBottom = (anchorRow + 1) * rowHeight + const dockTop = terminalFrameHeight - keyboardLift + const margin = rowHeight + return Math.min(keyboardLift, Math.max(0, anchorBottom + margin - dockTop)) +} diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts b/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts new file mode 100644 index 00000000000..a3a29ac61f9 --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts @@ -0,0 +1,43 @@ +export const TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS = ` + function lineHasVisibleContent(line, cell) { + if (line.translateToString(true).trim().length > 0) return true; + if (!cell || !line.getCell) return false; + var limit = Math.min(term.cols || 0, line.length || 0); + for (var x = 0; x < limit; x++) { + var current = line.getCell(x, cell); + if (!current) continue; + if (!current.isBgDefault() || current.isInverse()) return true; + if (typeof current.isUnderline === 'function' && current.isUnderline()) return true; + if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) return true; + if (typeof current.isOverline === 'function' && current.isOverline()) return true; + } + return false; + } + + function computeContentBottomRow() { + if (!term || !term.buffer || !term.buffer.active) return 0; + var buffer = term.buffer.active; + var top = buffer.viewportY || 0; + var cell = buffer.getNullCell ? buffer.getNullCell() : null; + for (var y = (term.rows || 0) - 1; y >= 0; y--) { + try { + var line = buffer.getLine(top + y); + if (line && lineHasVisibleContent(line, cell)) return y; + } catch (e) {} + } + return 0; + } + + function emitKeyboardAvoidanceMetrics() { + if (!term) return; + var alt = false; + try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} + notify({ + type: 'keyboard-avoidance-metrics', + cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0, + contentBottomRow: alt ? 0 : computeContentBottomRow(), + rows: term.rows || 0, + altScreen: alt + }); + } +` diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts new file mode 100644 index 00000000000..3a09ae3acd4 --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts @@ -0,0 +1,215 @@ +import { readFileSync } from 'node:fs' +import { Script } from 'node:vm' +import { Terminal } from '@xterm/xterm' +import { describe, expect, it, vi } from 'vitest' +import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected' +import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' + +const terminalHtmlSource = readFileSync( + new URL('./terminal-webview-html.ts', import.meta.url), + 'utf8' +) +const reflowSource = readFileSync( + new URL('./terminal-webview-reflow-injected.ts', import.meta.url), + 'utf8' +) + +type Cell = { isBgDefault: () => boolean; isInverse: () => number } +type MetricsNotification = { + type: string + cursorY: number + contentBottomRow: number + rows: number + altScreen: boolean +} + +function makeLine(text = '', styledColumns: number[] = []) { + const styled = new Set(styledColumns) + return { + isWrapped: false, + length: 10, + translateToString: vi.fn(() => text), + getCell: (column: number): Cell => ({ + isBgDefault: () => !styled.has(column), + isInverse: () => 0 + }) + } +} + +function runMetrics(lines: (ReturnType | undefined)[], altScreen = false) { + const notifications: Record[] = [] + const buffer = { + cursorY: 2, + viewportY: 3, + type: altScreen ? 'alternate' : 'normal', + getLine: (index: number) => lines[index - 3], + getNullCell: () => ({}) + } + const context = { + notifications, + notify: (message: Record) => notifications.push(message), + term: { buffer: { active: buffer }, cols: 10, rows: lines.length } + } + new Script( + `${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();` + ).runInNewContext(context) + return notifications[0] as MetricsNotification +} + +function runTerminalMetrics(term: Terminal) { + const notifications: Record[] = [] + new Script( + `${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();` + ).runInNewContext({ + notify: (message: Record) => notifications.push(message), + term + }) + return notifications[0] as MetricsNotification +} + +function write(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +describe('terminal keyboard-avoidance WebView metrics', () => { + it('finds text on wrapped rows using the visible viewport offset', () => { + const lines = [makeLine('header'), makeLine(''), makeLine('wrapped footer')] + lines[2]!.isWrapped = true + expect(runMetrics(lines)).toMatchObject({ contentBottomRow: 2 }) + }) + + it('supports cells without decoration APIs and keeps background-only ANSI chrome visible', () => { + expect(runMetrics([makeLine('header'), makeLine(''), makeLine('')])).toMatchObject({ + contentBottomRow: 0 + }) + expect(runMetrics([makeLine('header'), makeLine(''), makeLine('', [4])])).toMatchObject({ + contentBottomRow: 2 + }) + }) + + it('classifies real xterm text and styled whitespace by rendered visibility', async () => { + const cases = [ + { name: 'default spaces', data: ' ', expected: 0 }, + { name: 'text', data: 'footer', expected: 7 }, + { name: 'background', data: '\x1b[41m \x1b[0m', expected: 7 }, + { name: 'inverse', data: '\x1b[7m \x1b[0m', expected: 7 }, + { name: 'underline', data: '\x1b[4m \x1b[0m', expected: 7 }, + { name: 'strikethrough', data: '\x1b[9m \x1b[0m', expected: 7 }, + { name: 'overline', data: '\x1b[53m \x1b[0m', expected: 7 }, + // Hidden text still reserves TUI layout, so keyboard avoidance treats it as content. + { name: 'invisible text', data: '\x1b[8mfooter\x1b[0m', expected: 7 } + ] + + for (const { name, data, expected } of cases) { + const term = new Terminal({ cols: 10, rows: 8 }) + try { + await write(term, `\x1b[8;1H${data}`) + expect(runTerminalMetrics(term), name).toMatchObject({ contentBottomRow: expected }) + } finally { + term.dispose() + } + } + }) + + it('tracks the real xterm viewport and alternate screen', async () => { + const term = new Terminal({ cols: 10, rows: 4, scrollback: 100 }) + try { + await write(term, 'header\r\n\r\n\r\n\r\nfooter') + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 3, altScreen: false }) + term.scrollLines(-2) + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0, altScreen: false }) + await write(term, '\x1b[?1049h\x1b[4m \x1b[0m') + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0, altScreen: true }) + } finally { + term.dispose() + } + }) + + it('follows real xterm resize and reset state', async () => { + const term = new Terminal({ cols: 10, rows: 8 }) + try { + await write(term, '\x1b[8;1Hfooter') + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 7 }) + term.resize(10, 4) + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 3 }) + term.reset() + expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0 }) + } finally { + term.dispose() + } + }) + + it('keeps real xterm metrics compatible with old payloads', async () => { + const term = new Terminal({ cols: 10, rows: 8 }) + try { + await write(term, '\x1b[8;1Hfooter\x1b[2;1H') + const { cursorY, rows, altScreen } = runTerminalMetrics(term) + expect(parseTerminalKeyboardAvoidanceMetrics({ cursorY, rows, altScreen })).toEqual({ + cursorY: 1, + contentBottomRow: 1, + rows: 8, + altScreen: false + }) + } finally { + term.dispose() + } + }) + + it('releases real xterm metric observers across terminal lifecycles', async () => { + for (let cycle = 0; cycle < 25; cycle += 1) { + const term = new Terminal({ cols: 10, rows: 4 }) + let emissions = 0 + const observer = term.onWriteParsed(() => { + runTerminalMetrics(term) + emissions += 1 + }) + try { + await write(term, `cycle ${cycle}`) + expect(emissions).toBeGreaterThan(0) + observer.dispose() + const disposedAt = emissions + await write(term, ' after dispose') + expect(emissions).toBe(disposedAt) + } finally { + observer.dispose() + term.dispose() + } + } + }) + + it('stops at the first bottom-up match and skips scans on alternate screen', () => { + const footer = makeLine('footer') + const header = makeLine('header') + expect(runMetrics([header, makeLine(''), footer])).toMatchObject({ contentBottomRow: 2 }) + expect(footer.translateToString).toHaveBeenCalledTimes(1) + expect(header.translateToString).not.toHaveBeenCalled() + + footer.translateToString.mockImplementation(() => { + throw new Error('alternate screen must not scan') + }) + expect(runMetrics([header, makeLine(''), footer], true)).toMatchObject({ + altScreen: true, + contentBottomRow: 0 + }) + }) + + it('refreshes metrics after every buffer geometry reset', () => { + const resizeStart = terminalHtmlSource.indexOf(' function resize(cols, rows)') + const resizeEnd = terminalHtmlSource.indexOf('\n // reflow()', resizeStart) + const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {") + const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart) + const textScaleStart = terminalHtmlSource.indexOf(' function applyTextScale(scale)') + const textScaleEnd = terminalHtmlSource.indexOf('\n var panX', textScaleStart) + + for (const block of [ + terminalHtmlSource.slice(resizeStart, resizeEnd), + terminalHtmlSource.slice(clearStart, clearEnd), + terminalHtmlSource.slice(textScaleStart, textScaleEnd), + reflowSource + ]) { + expect(block.indexOf('emitKeyboardAvoidanceMetrics()')).toBeGreaterThan( + block.includes('term.resize') ? block.indexOf('term.resize') : block.indexOf('term.reset') + ) + } + }) +}) diff --git a/mobile/src/terminal/terminal-webview-contract.test.ts b/mobile/src/terminal/terminal-webview-contract.test.ts new file mode 100644 index 00000000000..0a3527111e3 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-contract.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' + +describe('parseTerminalKeyboardAvoidanceMetrics', () => { + it('parses a full payload', () => { + expect( + parseTerminalKeyboardAvoidanceMetrics({ + cursorY: 30, + contentBottomRow: 34, + rows: 40, + altScreen: true + }) + ).toEqual({ cursorY: 30, contentBottomRow: 34, rows: 40, altScreen: true }) + }) + + it('defaults contentBottomRow to cursorY when absent (older WebView bundles)', () => { + expect(parseTerminalKeyboardAvoidanceMetrics({ cursorY: 12, rows: 40 })).toEqual({ + cursorY: 12, + contentBottomRow: 12, + rows: 40, + altScreen: false + }) + }) + + it('defaults non-numeric fields to zero', () => { + expect(parseTerminalKeyboardAvoidanceMetrics({})).toEqual({ + cursorY: 0, + contentBottomRow: 0, + rows: 0, + altScreen: false + }) + }) + + it('bounds untrusted numeric fields to the reported viewport', () => { + expect( + parseTerminalKeyboardAvoidanceMetrics({ + cursorY: Number.POSITIVE_INFINITY, + contentBottomRow: 99.8, + rows: 40.7, + altScreen: 'true' + }) + ).toEqual({ cursorY: 0, contentBottomRow: 39, rows: 40, altScreen: false }) + expect( + parseTerminalKeyboardAvoidanceMetrics({ + cursorY: -4, + contentBottomRow: Number.NaN, + rows: -1 + }) + ).toEqual({ cursorY: 0, contentBottomRow: 0, rows: 0, altScreen: false }) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index 63c155b7a23..c28fd4a5038 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -14,10 +14,34 @@ export type TerminalModes = { export type TerminalKeyboardAvoidanceMetrics = { cursorY: number + // Main-buffer TUIs can render footer rows below the caret. + contentBottomRow: number rows: number altScreen: boolean } +export function parseTerminalKeyboardAvoidanceMetrics( + msg: Record +): TerminalKeyboardAvoidanceMetrics { + const rows = toNonNegativeInteger(msg.rows) + const maxRow = Math.max(0, rows - 1) + const cursorY = Math.min(toNonNegativeInteger(msg.cursorY), maxRow) + const contentBottomRow = + msg.contentBottomRow === undefined + ? cursorY + : Math.min(toNonNegativeInteger(msg.contentBottomRow), maxRow) + return { + cursorY, + contentBottomRow, + rows, + altScreen: msg.altScreen === true + } +} + +function toNonNegativeInteger(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0 +} + export type MobileTerminalTheme = RuntimeMobileTerminalTheme export type TerminalSelectionEvents = { diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index a7715e4f564..c3ba29bb542 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -3,6 +3,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ import { colors } from '../theme/mobile-theme' import { TERMINAL_TEXT_SCALES } from '../storage/preferences' import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' +import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' import { TERMINAL_SURFACE_SWAP_JS } from './terminal-webview-surface-swap-injected' @@ -287,6 +288,7 @@ window.onerror = function(msg) { if (cols < MIN_FIT_COLS) return; var rows = Math.max(8, Math.floor(window.innerHeight / cellH)); term.resize(cols, rows); + emitKeyboardAvoidanceMetrics(); } applyFitScale('text-scale'); }); @@ -802,6 +804,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} if (!term) return; initRows = rows || initRows; term.resize(cols || term.cols, rows || term.rows); + emitKeyboardAvoidanceMetrics(); applyFitScale('resize-msg'); notify({ type: 'ready', cols: cols, rows: rows }); } @@ -958,6 +961,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} initialOscLinkEvictionReady = false; if (term) { term.clear(); term.reset(); } emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); resetEvictionCounter(); if (selMode === 'select') { notify({ type: 'selection-evicted' }); @@ -1100,17 +1104,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} sgrMousePixelsMode: false }; - function emitKeyboardAvoidanceMetrics() { - if (!term) return; - var alt = false; - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - notify({ - type: 'keyboard-avoidance-metrics', - cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0, - rows: term.rows || 0, - altScreen: alt - }); - } + ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} function attachTermObservers() { if (!term) return; diff --git a/mobile/src/terminal/terminal-webview-notification-dispatch.test.ts b/mobile/src/terminal/terminal-webview-notification-dispatch.test.ts new file mode 100644 index 00000000000..bcd777a2e8f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-notification-dispatch.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' + +import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch' + +describe('dispatchTerminalWebViewNotification', () => { + it('preserves bounded keyboard metrics through the dispatcher', () => { + const onKeyboardAvoidanceMetrics = vi.fn() + dispatchTerminalWebViewNotification( + { + type: 'keyboard-avoidance-metrics', + cursorY: 30, + contentBottomRow: 99, + rows: 40, + altScreen: true + }, + { onKeyboardAvoidanceMetrics, reportEngineError: vi.fn() } + ) + expect(onKeyboardAvoidanceMetrics).toHaveBeenCalledWith({ + cursorY: 30, + contentBottomRow: 39, + rows: 40, + altScreen: true + }) + }) + + it('keeps old WebView payloads cursor-compatible', () => { + const onKeyboardAvoidanceMetrics = vi.fn() + dispatchTerminalWebViewNotification( + { type: 'keyboard-avoidance-metrics', cursorY: 12, rows: 40 }, + { onKeyboardAvoidanceMetrics, reportEngineError: vi.fn() } + ) + expect(onKeyboardAvoidanceMetrics).toHaveBeenCalledWith({ + cursorY: 12, + contentBottomRow: 12, + rows: 40, + altScreen: false + }) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-notification-dispatch.ts b/mobile/src/terminal/terminal-webview-notification-dispatch.ts index 372df650fe0..77019d3f996 100644 --- a/mobile/src/terminal/terminal-webview-notification-dispatch.ts +++ b/mobile/src/terminal/terminal-webview-notification-dispatch.ts @@ -1,4 +1,7 @@ -import type { TerminalSelectionEvents } from './terminal-webview-contract' +import { + parseTerminalKeyboardAvoidanceMetrics, + type TerminalSelectionEvents +} from './terminal-webview-contract' export type TerminalWebViewNotificationHandlers = Omit< TerminalSelectionEvents, @@ -63,13 +66,7 @@ export function dispatchTerminalWebViewNotification( handlers.onOpenUrl?.(url) } } else if (msg.type === 'keyboard-avoidance-metrics') { - const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0 - const rows = typeof msg.rows === 'number' ? msg.rows : 0 - handlers.onKeyboardAvoidanceMetrics?.({ - cursorY, - rows, - altScreen: !!msg.altScreen - }) + handlers.onKeyboardAvoidanceMetrics?.(parseTerminalKeyboardAvoidanceMetrics(msg)) } else if (msg.type === 'haptic') { const kind = msg.kind if (kind === 'selection' || kind === 'success' || kind === 'error' || kind === 'edge-bump') { diff --git a/mobile/src/terminal/terminal-webview-reflow-injected.ts b/mobile/src/terminal/terminal-webview-reflow-injected.ts index 4fea62f5107..ef704529e4b 100644 --- a/mobile/src/terminal/terminal-webview-reflow-injected.ts +++ b/mobile/src/terminal/terminal-webview-reflow-injected.ts @@ -28,5 +28,6 @@ export const TERMINAL_REFLOW_JS = ` } applyFitScale('reflow-msg'); updateScrollIndicator(false); + emitKeyboardAvoidanceMetrics(); } ` From 79d3c847bd4fc5be734f2d38479596ea09ef08b9 Mon Sep 17 00:00:00 2001 From: BingZ Date: Tue, 4 Aug 2026 11:35:40 +0800 Subject: [PATCH 023/101] fix(runtime): attribute destructive close requests (#12238) Attribute destructive runtime and daemon close diagnostics to the requesting client and exact target, record outcomes only after completion, and add reliability-gated attribution regressions. --- config/reliability-gates.jsonc | 17 +- .../daemon-server-kill-attribution.test.ts | 119 ++++++++++ src/main/daemon/daemon-server.ts | 10 +- .../rpc/methods/session-tab-close-methods.ts | 9 + src/main/runtime/rpc/methods/terminal.ts | 21 +- ...runtime-close-attribution-topology.test.ts | 211 ++++++++++++++++++ .../rpc/runtime-close-attribution.test.ts | 129 +++++++++++ .../runtime/rpc/terminal-close-attribution.ts | 50 +++++ 8 files changed, 558 insertions(+), 8 deletions(-) create mode 100644 src/main/daemon/daemon-server-kill-attribution.test.ts create mode 100644 src/main/runtime/rpc/runtime-close-attribution-topology.test.ts create mode 100644 src/main/runtime/rpc/runtime-close-attribution.test.ts create mode 100644 src/main/runtime/rpc/terminal-close-attribution.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 802526681da..c84075e6c04 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1815,13 +1815,15 @@ "invariant": "Reconnect, replay, or lifecycle observations from a viewer that negotiated explicit close intent must never kill a live PTY. A capable reasonless close must keep and republish; a legacy paired viewer must retain current-main behavior because its intentional close and cleanup echo are wire-identical. Lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Legacy daemon hello and warm reattachment remain non-destructive.", "oracle": "Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-server-kill-attribution.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/runtime-close-attribution-topology.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ + "src/main/daemon/daemon-server-kill-attribution.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/remote-runtime-request-connection.integration.test.ts", + "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts", "src/main/runtime/rpc/methods/session-tabs.test.ts", "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts", "src/main/runtime/rpc/e2ee-channel.test.ts", @@ -1843,6 +1845,19 @@ "tests/e2e/daemon-generation-legacy-close-safety.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/daemon/daemon-server-kill-attribution.test.ts", + "assertions": [ + "successful and failed daemon kill requests retain the authenticated control-client identity without claiming a failed kill succeeded" + ] + }, + { + "file": "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts", + "assertions": [ + "remote, legacy, reconnect, stale, concurrent, cross-worktree, and unowned close spans retain authoritative runtime, target, requester, decision, and outcome identities without serializing the bearer credential", + "close attribution adds no terminal inventory scan or provider fanout" + ] + }, { "file": "src/main/runtime/remote-runtime-request-connection.integration.test.ts", "assertions": [ diff --git a/src/main/daemon/daemon-server-kill-attribution.test.ts b/src/main/daemon/daemon-server-kill-attribution.test.ts new file mode 100644 index 00000000000..8b324043358 --- /dev/null +++ b/src/main/daemon/daemon-server-kill-attribution.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DaemonServer } from './daemon-server' +import { SessionNotFoundError, type DaemonRequest } from './types' + +type DaemonServerPrivate = { + host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise } + pendingPtySpawnPreparations: Map> + routeRequest(clientId: string, request: DaemonRequest): Promise +} + +describe('daemon kill attribution', () => { + let server: DaemonServer + let dir: string + + afterEach(async () => { + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + it('records the requesting control client in the session-killed log', async () => { + dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-')) + const killLog = { log: vi.fn(), close: vi.fn() } + server = new DaemonServer({ + socketPath: join(dir, 'daemon.sock'), + tokenPath: join(dir, 'daemon.token'), + log: killLog, + spawnSubprocess: () => { + throw new Error('not used') + } + }) + const daemon = server as unknown as DaemonServerPrivate + let finishKill!: () => void + const killFinished = new Promise((resolve) => { + finishKill = resolve + }) + vi.spyOn(daemon.host, 'kill').mockReturnValue(killFinished) + + const request = daemon.routeRequest('control-42', { + id: 'kill-1', + type: 'kill', + payload: { sessionId: 'agent-session', immediate: true } + }) + + await vi.waitFor(() => expect(daemon.host.kill).toHaveBeenCalledOnce()) + expect(killLog.log).not.toHaveBeenCalledWith('session-killed', expect.anything()) + finishKill() + await request + + expect(killLog.log).toHaveBeenCalledWith('session-killed', { + sessionId: 'agent-session', + immediate: true, + clientId: 'control-42' + }) + }) + + it('attributes a failed kill without claiming the session was killed', async () => { + dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-')) + const killLog = { log: vi.fn(), close: vi.fn() } + server = new DaemonServer({ + socketPath: join(dir, 'daemon.sock'), + tokenPath: join(dir, 'daemon.token'), + log: killLog, + spawnSubprocess: () => { + throw new Error('not used') + } + }) + const daemon = server as unknown as DaemonServerPrivate + vi.spyOn(daemon.host, 'kill').mockRejectedValue(new Error('kill refused')) + + await expect( + daemon.routeRequest('control-42', { + id: 'kill-1', + type: 'kill', + payload: { sessionId: 'agent-session', immediate: true } + }) + ).rejects.toThrow('kill refused') + + expect(killLog.log).toHaveBeenCalledWith('session-kill-failed', { + sessionId: 'agent-session', + immediate: true, + clientId: 'control-42' + }) + expect(killLog.log).not.toHaveBeenCalledWith('session-killed', expect.anything()) + }) + + it('attributes a tolerated kill after canceling a pending spawn', async () => { + dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-')) + const killLog = { log: vi.fn(), close: vi.fn() } + server = new DaemonServer({ + socketPath: join(dir, 'daemon.sock'), + tokenPath: join(dir, 'daemon.token'), + log: killLog, + spawnSubprocess: () => { + throw new Error('not used') + } + }) + const daemon = server as unknown as DaemonServerPrivate + const pendingPreparation = { canceled: false, clientId: 'control-42' } + daemon.pendingPtySpawnPreparations.set('agent-session', new Set([pendingPreparation])) + vi.spyOn(daemon.host, 'kill').mockRejectedValue(new SessionNotFoundError('agent-session')) + + await daemon.routeRequest('control-42', { + id: 'kill-1', + type: 'kill', + payload: { sessionId: 'agent-session', immediate: true } + }) + + expect(pendingPreparation.canceled).toBe(true) + expect(killLog.log).toHaveBeenCalledWith('session-killed', { + sessionId: 'agent-session', + immediate: true, + clientId: 'control-42' + }) + expect(killLog.log).not.toHaveBeenCalledWith('session-kill-failed', expect.anything()) + }) +}) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 90b3195766d..4c9ffd5098e 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -935,18 +935,22 @@ export class DaemonServer { request.payload.sessionId ) this.lastInputAtBySessionId.delete(request.payload.sessionId) - this.log.log('session-killed', { + const attribution = { sessionId: request.payload.sessionId, - immediate: request.payload.immediate === true - }) + immediate: request.payload.immediate === true, + // Daemon control identity, not the paired-device bearer credential. + clientId + } try { 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)) { + this.log.log('session-kill-failed', attribution) throw error } } + this.log.log('session-killed', attribution) return {} } diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index eabffa24a73..8b17d946bdf 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -43,7 +43,11 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ kind: 'client', attributes: { attribution: 'session-tab-close', + runtimeId: context.runtime.getRuntimeId(), origin: context.clientKind ?? 'in-process', + deviceId: context.pairedDeviceId ?? 'in-process', + worktree: params.worktree, + tabId: params.tabId, closeReason: params.reason ?? (requiresIntent @@ -85,7 +89,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ kind: 'client', attributes: { attribution: 'session-tab-lifecycle-close', + runtimeId: context.runtime.getRuntimeId(), origin: context.clientKind ?? 'in-process', + deviceId: context.pairedDeviceId ?? 'in-process', + worktree: params.worktree, + tabId: params.tabId, + terminal: params.terminal, closeReason: params.reason, connectionGeneration: context.connectionId ?? 'in-process', requestId: context.requestId ?? 'in-process', diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index d20524749bb..edef5f44083 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -72,6 +72,7 @@ import { type TerminalOutputSourceRange } from '../../../../shared/terminal-output-source-range' import type { RemoteTerminalSourceRangeReplacementReservation } from '../../remote-terminal-source-range-consumer' +import { withTerminalCloseAttribution } from '../terminal-close-attribution' const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 const TERMINAL_OUTPUT_FLUSH_MS = 5 @@ -1499,15 +1500,27 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.close', params: TerminalHandle, - handler: async (params, { runtime }) => ({ - close: await runtime.closeTerminal(params.terminal) + handler: async (params, context) => ({ + close: await withTerminalCloseAttribution( + 'terminal.close', + context, + 'terminal', + params.terminal, + () => context.runtime.closeTerminal(params.terminal) + ) }) }), defineMethod({ name: 'terminal.closeTab', params: TerminalHandle, - handler: async (params, { runtime }) => ({ - close: await runtime.closeTerminalTab(params.terminal) + handler: async (params, context) => ({ + close: await withTerminalCloseAttribution( + 'terminal.closeTab', + context, + 'terminal-tab', + params.terminal, + () => context.runtime.closeTerminalTab(params.terminal) + ) }) }), defineMethod({ diff --git a/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts b/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts new file mode 100644 index 00000000000..4e1232e7358 --- /dev/null +++ b/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { _resetTracerForTests, setActiveSink, type TracerSink } from '../../observability/tracer' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { SESSION_TAB_METHODS } from './methods/session-tabs' +import { TERMINAL_METHODS } from './methods/terminal' + +const BEARER_CLIENT_ID = 'secret-bearer-client-id' + +type SpanRecord = { + name: string + attributes: Record + exit: { _tag: string } +} + +function request(id: string, method: string, params: unknown): RpcRequest { + return { id, authToken: 'test-token', method, params } +} + +describe('runtime close attribution topology', () => { + const records: SpanRecord[] = [] + + beforeEach(() => { + records.length = 0 + setActiveSink({ + push: (record) => records.push(record as SpanRecord), + flush: vi.fn(), + close: vi.fn() + } satisfies TracerSink) + }) + + afterEach(() => _resetTracerForTests()) + + it('identifies the authoritative runtime and target for remote, stale, and legacy closes', async () => { + const closeMobileSessionTab = vi + .fn() + .mockResolvedValueOnce({ closed: true, refused: true, refusalReason: 'stale-terminal' }) + .mockResolvedValue({ closed: true }) + const runtime = { + getRuntimeId: () => 'runtime-owner-1', + closeMobileSessionTab, + refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({ closed: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await dispatcher.dispatchStreaming( + request('stale-1', 'session.tabs.closeLifecycle', { + worktree: 'id:worktree-a', + tabId: 'tab-a', + reason: 'cleanup', + publicationEpoch: 'epoch-old', + terminal: 'terminal-old' + }), + vi.fn(), + { + clientId: BEARER_CLIENT_ID, + pairedDeviceId: 'device-1', + connectionId: 'connection-before-reconnect', + clientKind: 'runtime' + } + ) + await dispatcher.dispatchStreaming( + request('legacy-1', 'session.tabs.close', { + worktree: 'id:worktree-b', + tabId: 'tab-b' + }), + vi.fn(), + { + clientId: BEARER_CLIENT_ID, + pairedDeviceId: 'device-1', + connectionId: 'connection-after-reconnect', + clientKind: 'runtime', + clientCapabilities: [] + } + ) + + expect(records).toHaveLength(2) + expect(records[0]).toMatchObject({ + name: 'runtime.session-tabs.close-lifecycle', + attributes: { + runtimeId: 'runtime-owner-1', + worktree: 'id:worktree-a', + tabId: 'tab-a', + terminal: 'terminal-old', + deviceId: 'device-1', + connectionGeneration: 'connection-before-reconnect', + requestId: 'stale-1', + decision: 'refused-stale-terminal' + }, + exit: { _tag: 'Success' } + }) + expect(records[1]).toMatchObject({ + name: 'runtime.session-tabs.close', + attributes: { + runtimeId: 'runtime-owner-1', + worktree: 'id:worktree-b', + tabId: 'tab-b', + deviceId: 'device-1', + connectionGeneration: 'connection-after-reconnect', + requestId: 'legacy-1', + closeReason: 'legacy-runtime-user', + decision: 'allowed' + } + }) + expect(JSON.stringify(records)).not.toContain(BEARER_CLIENT_ID) + }) + + it('keeps concurrent cross-worktree request and target identities distinct', async () => { + const closeMobileSessionTab = vi.fn().mockResolvedValue({ closed: true }) + const runtime = { + getRuntimeId: () => 'runtime-owner-2', + closeMobileSessionTab + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await Promise.all( + ( + [ + ['request-a', 'device-a', 'connection-a', 'worktree-a', 'tab-a'], + ['request-b', 'device-b', 'connection-b', 'worktree-b', 'tab-b'] + ] as const + ).map(([id, deviceId, connectionId, worktree, tabId]) => + dispatcher.dispatchStreaming( + request(id, 'session.tabs.close', { + worktree: `id:${worktree}`, + tabId, + reason: 'user' + }), + vi.fn(), + { clientKind: 'runtime', pairedDeviceId: deviceId, connectionId } + ) + ) + ) + + expect(records.map((record) => record.attributes)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + runtimeId: 'runtime-owner-2', + worktree: 'id:worktree-a', + tabId: 'tab-a', + deviceId: 'device-a', + connectionGeneration: 'connection-a', + requestId: 'request-a' + }), + expect.objectContaining({ + runtimeId: 'runtime-owner-2', + worktree: 'id:worktree-b', + tabId: 'tab-b', + deviceId: 'device-b', + connectionGeneration: 'connection-b', + requestId: 'request-b' + }) + ]) + ) + expect(closeMobileSessionTab).toHaveBeenCalledTimes(2) + }) + + it('records resolved terminal outcome and failed unowned requests without extra inventory', async () => { + const closeTerminal = vi + .fn() + .mockResolvedValueOnce({ handle: 'terminal-live', tabId: 'tab-live', ptyKilled: true }) + .mockRejectedValueOnce(new Error('terminal_handle_stale')) + const listSessions = vi.fn() + const runtime = { + getRuntimeId: () => 'runtime-owner-3', + closeTerminal, + listSessions + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const context = { + clientKind: 'runtime' as const, + pairedDeviceId: 'device-terminal', + connectionId: 'connection-terminal' + } + + await dispatcher.dispatchStreaming( + request('terminal-live-request', 'terminal.close', { terminal: 'terminal-live' }), + vi.fn(), + context + ) + await dispatcher.dispatchStreaming( + request('terminal-stale-request', 'terminal.close', { terminal: 'terminal-stale' }), + vi.fn(), + context + ) + + expect(records[0]).toMatchObject({ + attributes: { + runtimeId: 'runtime-owner-3', + terminal: 'terminal-live', + tabId: 'tab-live', + ptyKilled: true, + decision: 'allowed', + outcome: 'succeeded' + }, + exit: { _tag: 'Success' } + }) + expect(records[1]).toMatchObject({ + attributes: { + runtimeId: 'runtime-owner-3', + terminal: 'terminal-stale', + decision: 'allowed', + outcome: 'failed' + }, + exit: { _tag: 'Failure' } + }) + expect(closeTerminal).toHaveBeenCalledTimes(2) + expect(listSessions).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/runtime-close-attribution.test.ts b/src/main/runtime/rpc/runtime-close-attribution.test.ts new file mode 100644 index 00000000000..0073348ec56 --- /dev/null +++ b/src/main/runtime/rpc/runtime-close-attribution.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { _resetTracerForTests, setActiveSink, type TracerSink } from '../../observability/tracer' +import type { OrcaRuntimeService } from '../orca-runtime' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import { SESSION_TAB_METHODS } from './methods/session-tabs' +import { TERMINAL_METHODS } from './methods/terminal' + +const BEARER_CLIENT_ID = 'secret-bearer-client-id' + +type CapturingSink = TracerSink & { records: unknown[] } + +function capturingSink(): CapturingSink { + const records: unknown[] = [] + return { + records, + push: (record) => records.push(record), + flush: vi.fn(), + close: vi.fn() + } +} + +function request(method: string, params: unknown): RpcRequest { + return { id: 'close-request-1', authToken: 'test-token', method, params } +} + +describe('runtime close attribution', () => { + let sink: CapturingSink + + beforeEach(() => { + sink = capturingSink() + setActiveSink(sink) + }) + + afterEach(() => { + _resetTracerForTests() + }) + + it.each([ + ['terminal.close', 'closeTerminal', 'terminal'], + ['terminal.closeTab', 'closeTerminalTab', 'terminal-tab'] + ] as const)( + 'records %s with the device identity and without the bearer credential', + async (method, call, targetKind) => { + const close = vi.fn().mockResolvedValue({ handle: 'term-1', ptyKilled: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + [call]: close + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + request(method, { terminal: 'term-1' }), + (reply) => replies.push(reply), + { + clientId: BEARER_CLIENT_ID, + pairedDeviceId: 'device-uuid-123', + connectionId: 'connection-7', + clientKind: 'runtime' + } + ) + + expect(JSON.parse(replies[0]!)).toMatchObject({ ok: true }) + expect(close).toHaveBeenCalledWith('term-1') + expect(sink.records).toEqual([ + expect.objectContaining({ + type: 'effect-span', + name: method, + attributes: expect.objectContaining({ + attribution: 'terminal-close', + origin: 'runtime', + deviceId: 'device-uuid-123', + connectionGeneration: 'connection-7', + requestId: 'close-request-1', + targetKind, + terminal: 'term-1', + decision: 'allowed' + }), + exit: { _tag: 'Success' } + }) + ]) + expect(JSON.stringify(sink.records)).not.toContain(BEARER_CLIENT_ID) + } + ) + + it.each([ + [ + 'session.tabs.close', + { worktree: 'id:wt-1', tabId: 'tab-1', reason: 'user' }, + 'runtime.session-tabs.close' + ], + [ + 'session.tabs.closeLifecycle', + { + worktree: 'id:wt-1', + tabId: 'tab-1', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminal: 'term-1' + }, + 'runtime.session-tabs.close-lifecycle' + ] + ] as const)( + 'records the device identity for %s without the bearer credential', + async (method, params, spanName) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await dispatcher.dispatchStreaming(request(method, params), vi.fn(), { + clientId: BEARER_CLIENT_ID, + pairedDeviceId: 'device-uuid-123', + connectionId: 'connection-7', + clientKind: 'runtime' + }) + + expect(sink.records).toEqual([ + expect.objectContaining({ + name: spanName, + attributes: expect.objectContaining({ deviceId: 'device-uuid-123' }) + }) + ]) + expect(JSON.stringify(sink.records)).not.toContain(BEARER_CLIENT_ID) + } + ) +}) diff --git a/src/main/runtime/rpc/terminal-close-attribution.ts b/src/main/runtime/rpc/terminal-close-attribution.ts new file mode 100644 index 00000000000..01eaa4e05a4 --- /dev/null +++ b/src/main/runtime/rpc/terminal-close-attribution.ts @@ -0,0 +1,50 @@ +import { withSpan } from '../../observability/tracer' +import type { RuntimeTerminalClose } from '../../../shared/runtime-types' +import type { RpcContext } from './core' + +type TerminalCloseMethod = 'terminal.close' | 'terminal.closeTab' +type TerminalCloseTargetKind = 'terminal' | 'terminal-tab' + +export function withTerminalCloseAttribution( + method: TerminalCloseMethod, + context: Pick< + RpcContext, + 'runtime' | 'clientKind' | 'pairedDeviceId' | 'connectionId' | 'requestId' + >, + targetKind: TerminalCloseTargetKind, + terminal: string, + close: () => Promise +): Promise { + return withSpan( + method, + async (span) => { + span.setAttribute('decision', 'allowed') + try { + const result = await close() + span.setAttribute('outcome', 'succeeded') + span.setAttribute('tabId', result.tabId) + span.setAttribute('ptyKilled', result.ptyKilled) + if (result.closeMode) { + span.setAttribute('closeMode', result.closeMode) + } + return result + } catch (error) { + span.setAttribute('outcome', 'failed') + throw error + } + }, + { + kind: 'client', + attributes: { + attribution: 'terminal-close', + runtimeId: context.runtime.getRuntimeId(), + origin: context.clientKind ?? 'in-process', + deviceId: context.pairedDeviceId ?? 'in-process', + connectionGeneration: context.connectionId ?? 'in-process', + requestId: context.requestId ?? 'in-process', + targetKind, + terminal + } + } + ) +} From 50594c55a9e54f604a7bd4e35c0adcc1c4728a0e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:45:23 -0700 Subject: [PATCH 024/101] Stop the Windows Orca CLI from crashing when the environment carries both PATH and Path (#12218) * fix(windows): stop the Orca CLI dying on a duplicated PATH/Path environment The packaged Windows `orca.exe` launcher read `ProcessStartInfo.EnvironmentVariables`, whose lazy getter copies the case-sensitive process block into a case-insensitive dictionary via `.Add`. An inherited block carrying both `PATH` and `Path` threw `ArgumentException: Item has already been added. Key in dictionary: 'PATH'`, so every `orca` invocation exited 1 before Electron started (native/windows-cli-launcher/OrcaCliLauncher.cs:46, printed at :67). The launcher now mutates its own environment with `Environment.SetEnvironmentVariable` and never touches either `ProcessStartInfo` env property, so `CreateProcess` passes a NULL environment block and the child inherits the live one verbatim. Orca was also minting the duplicate itself. `applyTerminalAttributionEnv` read `baseEnv.PATH` and unconditionally wrote `baseEnv.PATH`, so a Windows PTY that inherited `Path` got a second spelling; which one the child resolved was non-deterministic. `createLaunchEnv` did the same and, because its read always missed on Windows, shipped Agent Teams terminals a `PATH` containing only the tmux shim dir. `resolvePathEnvKey` (extracted from the existing precedent in windows-environment-path.ts) now drives every PATH read and write in the PTY env pipeline, and attribution collapses Windows onto the single OS-resolved spelling. Off Windows the resolver always returns `PATH`, so POSIX behavior is unchanged and a case-sensitive POSIX `Path` variable is never touched. Closes #12046 * test(windows): track the launcher's own-environment marker The #12046 fix moved ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER and ORCA_CLI_COMMAND off ProcessStartInfo.EnvironmentVariables, but this asset test still pinned the old dictionary writes and failed. Co-authored-by: Orca * fix(windows): follow the host block's PATH spelling on sparse daemon env patches Resolving a path-less Windows env to `Path` handed the daemon's own `{...process.env, ...opts.env}` merge both spellings when the host block spelt `PATH`. Fall back to the host block's own key, and collapse again inside the daemon since that merge happens after attribution. Co-authored-by: Orca * fix(windows): resolve the live PATH spelling by block order, not casing Win32 resolves a duplicated variable by taking the first case-insensitive match in the block, so `resolvePathEnvKey`'s hardcoded `Path`-first preference targeted the shadowed spelling on the reporter's own `["PATH","Path"]` block. Drop the attribution-side collapse with it: it deleted the other spelling's value, and deleting the live key promotes the shadowed one, so an env that stripped down to empty lost both. * chore: drop unrelated merge formatting --------- Co-authored-by: Orca Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../build-windows-cli-launcher.test.mjs | 90 +++++++++- .../fixtures/DuplicatePathProcessLauncher.cs | 123 +++++++++++++ .../windows-cli-launcher/OrcaCliLauncher.cs | 37 ++-- ...rminal-attribution-path-env-casing.test.ts | 169 ++++++++++++++++++ src/main/attribution/terminal-attribution.ts | 13 +- src/main/cli/windows-launcher-asset.test.ts | 11 +- src/main/daemon/pty-subprocess.test.ts | 71 ++++++++ src/main/daemon/pty-subprocess.ts | 45 ++++- src/main/ipc/pty.ts | 17 +- src/main/providers/local-pty-provider.test.ts | 25 +++ src/main/providers/local-pty-provider.ts | 12 +- src/main/pty/windows-environment-path.test.ts | 52 +++++- src/main/pty/windows-environment-path.ts | 31 +++- .../claude-agent-teams-service.test.ts | 20 +++ .../runtime/claude-agent-teams-service.ts | 7 +- 15 files changed, 674 insertions(+), 49 deletions(-) create mode 100644 config/scripts/fixtures/DuplicatePathProcessLauncher.cs create mode 100644 src/main/attribution/terminal-attribution-path-env-casing.test.ts diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 0d14e2373b6..6e4803ed7ba 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -1,4 +1,12 @@ -import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { spawnSync } from 'node:child_process' @@ -31,6 +39,20 @@ describe('Windows CLI launcher', () => { } }) + itCrossHost('never materializes the child environment block from ProcessStartInfo', () => { + // Why: both ProcessStartInfo env properties copy the process block into a case-insensitive + // dictionary that throws when the inherited block holds PATH and Path (stablyai/orca#12046). + const source = readFileSync( + join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs'), + 'utf8' + ) + const code = source.replace(/^\s*\/\/.*$/gm, '') + + expect(code).not.toContain('EnvironmentVariables') + expect(code).not.toContain('startInfo.Environment') + expect(code).toContain('Environment.SetEnvironmentVariable') + }) + itWindows('preserves a multiline argument from PowerShell through the native launcher', () => { const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher ')) try { @@ -89,4 +111,70 @@ describe('Windows CLI launcher', () => { rmSync(appRoot, { recursive: true, force: true }) } }) + + itWindows('survives an inherited environment block containing PATH and Path', () => { + const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher ')) + try { + const resourcesPath = join(appRoot, 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca.exe') + const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + const outputPath = join(appRoot, 'child-result.json') + const harnessSourcePath = join( + projectRoot, + 'config', + 'scripts', + 'fixtures', + 'DuplicatePathProcessLauncher.cs' + ) + const harnessPath = join(appRoot, 'DuplicatePathLauncher.exe') + mkdirSync(dirname(launcherPath), { recursive: true }) + mkdirSync(dirname(cliPath), { recursive: true }) + copyFileSync(process.execPath, join(appRoot, 'Orca.exe')) + writeFileSync( + cliPath, + `require('node:fs').writeFileSync(process.env.ORCA_TEST_OUTPUT, JSON.stringify({ + electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE, + pathKeys: Object.keys(process.env).filter((key) => key.toLowerCase() === 'path') +}))\n`, + 'utf8' + ) + const build = spawnSync( + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath], + { cwd: projectRoot, encoding: 'utf8' } + ) + expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0) + + const compiler = findFrameworkCompiler() + expect(compiler).not.toBeNull() + const compileHarness = spawnSync( + compiler, + ['/nologo', '/target:exe', `/out:${harnessPath}`, harnessSourcePath], + { encoding: 'utf8' } + ) + expect(compileHarness.status, `${compileHarness.stdout}\n${compileHarness.stderr}`).toBe(0) + + const launch = spawnSync(harnessPath, [launcherPath, outputPath], { encoding: 'utf8' }) + expect(launch.status, `${launch.stdout}\n${launch.stderr}`).toBe(0) + expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({ + electronRunAsNode: '1', + pathKeys: ['PATH', 'Path'] + }) + } finally { + rmSync(appRoot, { recursive: true, force: true }) + } + }) }) + +function findFrameworkCompiler() { + const windowsDirectory = process.env.WINDIR ?? process.env.SystemRoot + if (!windowsDirectory) { + return null + } + return ( + [ + join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'), + join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe') + ].find((candidate) => existsSync(candidate)) ?? null + ) +} diff --git a/config/scripts/fixtures/DuplicatePathProcessLauncher.cs b/config/scripts/fixtures/DuplicatePathProcessLauncher.cs new file mode 100644 index 00000000000..6b46382103b --- /dev/null +++ b/config/scripts/fixtures/DuplicatePathProcessLauncher.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +internal static class DuplicatePathProcessLauncher +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + public int cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateProcess( + string applicationName, + StringBuilder commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + bool inheritHandles, + uint creationFlags, + IntPtr environment, + string currentDirectory, + ref StartupInfo startupInfo, + out ProcessInformation processInformation + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr handle); + + private static int Main(string[] args) + { + List entries = new List(); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + if (!String.Equals((string)entry.Key, "PATH", StringComparison.OrdinalIgnoreCase)) + { + entries.Add((string)entry.Key + "=" + (string)entry.Value); + } + } + entries.Add("PATH=C:\\live"); + entries.Add("Path=C:\\shadowed"); + entries.Add("ORCA_TEST_OUTPUT=" + args[1]); + + IntPtr environment = Marshal.StringToHGlobalUni(String.Join("\0", entries.ToArray()) + "\0\0"); + StartupInfo startupInfo = new StartupInfo(); + startupInfo.cb = Marshal.SizeOf(startupInfo); + ProcessInformation processInformation; + try + { + bool started = CreateProcess( + args[0], + new StringBuilder("\"" + args[0] + "\""), + IntPtr.Zero, + IntPtr.Zero, + false, + 0x00000400, + environment, + null, + ref startupInfo, + out processInformation + ); + if (!started) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + finally + { + Marshal.FreeHGlobal(environment); + } + + try + { + WaitForSingleObject(processInformation.hProcess, 0xffffffff); + uint exitCode; + if (!GetExitCodeProcess(processInformation.hProcess, out exitCode)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return (int)exitCode; + } + finally + { + CloseHandle(processInformation.hThread); + CloseHandle(processInformation.hProcess); + } + } +} diff --git a/native/windows-cli-launcher/OrcaCliLauncher.cs b/native/windows-cli-launcher/OrcaCliLauncher.cs index 8f89e303413..6261775859b 100644 --- a/native/windows-cli-launcher/OrcaCliLauncher.cs +++ b/native/windows-cli-launcher/OrcaCliLauncher.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Specialized; using System.Diagnostics; using System.IO; using System.Text; @@ -43,18 +42,17 @@ internal static class OrcaCliLauncher // Why: launching without cmd.exe preserves embedded newlines while matching the // packaged batch launcher's Electron-as-Node environment contract. - MoveEnvironmentVariable(startInfo.EnvironmentVariables, "NODE_OPTIONS", "ORCA_NODE_OPTIONS"); - MoveEnvironmentVariable( - startInfo.EnvironmentVariables, - "NODE_REPL_EXTERNAL_MODULE", - "ORCA_NODE_REPL_EXTERNAL_MODULE" + // Why: ProcessStartInfo's env copy rejects duplicate PATH/Path keys; mutating this + // short-lived process preserves the native block for child inheritance (#12046). + MoveEnvironmentVariable("NODE_OPTIONS", "ORCA_NODE_OPTIONS"); + MoveEnvironmentVariable("NODE_REPL_EXTERNAL_MODULE", "ORCA_NODE_REPL_EXTERNAL_MODULE"); + Environment.SetEnvironmentVariable("ELECTRON_RUN_AS_NODE", "1"); + Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); + string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND"); + Environment.SetEnvironmentVariable( + "ORCA_CLI_COMMAND", + requestedCliCommand == "orca-ide" ? "orca-ide" : "orca" ); - startInfo.EnvironmentVariables["ELECTRON_RUN_AS_NODE"] = "1"; - startInfo.EnvironmentVariables["ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"] = "1"; - startInfo.EnvironmentVariables["ORCA_CLI_COMMAND"] = - Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND") == "orca-ide" - ? "orca-ide" - : "orca"; using (Process child = Process.Start(startInfo)) { @@ -69,19 +67,12 @@ internal static class OrcaCliLauncher } } - private static void MoveEnvironmentVariable( - StringDictionary environment, - string sourceName, - string targetName - ) + private static void MoveEnvironmentVariable(string sourceName, string targetName) { string value = Environment.GetEnvironmentVariable(sourceName); - environment.Remove(sourceName); - environment.Remove(targetName); - if (value != null) - { - environment[targetName] = value; - } + Environment.SetEnvironmentVariable(sourceName, null); + // Why: a null value clears the target, matching the previous unconditional Remove. + Environment.SetEnvironmentVariable(targetName, value); } private static string BuildArguments(string cliPath, string[] args) diff --git a/src/main/attribution/terminal-attribution-path-env-casing.test.ts b/src/main/attribution/terminal-attribution-path-env-casing.test.ts new file mode 100644 index 00000000000..95ff6fdc2e3 --- /dev/null +++ b/src/main/attribution/terminal-attribution-path-env-casing.test.ts @@ -0,0 +1,169 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { applyTerminalAttributionEnv } from './terminal-attribution' + +describe('applyTerminalAttributionEnv PATH key casing', () => { + let tmpRoot: string | null = null + + afterEach(() => { + if (tmpRoot) { + rmSync(tmpRoot, { force: true, recursive: true }) + tmpRoot = null + } + }) + + function makeTmpRoot(): string { + tmpRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-path-casing-')) + return tmpRoot + } + + // Why: a Windows child that inherits both spellings crashes the packaged orca.exe launcher + // with "Item has already been added. Key in dictionary: 'PATH'" (stablyai/orca#12046). + function pathEnvKeys(env: Record): string[] { + return Object.keys(env).filter((key) => /^path$/i.test(key)) + } + + it('writes back the inherited Windows `Path` spelling instead of adding `PATH`', () => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const baseEnv: Record = { + Path: 'C:\\Windows\\system32;C:\\Program Files\\Git\\cmd', + SystemRoot: 'C:\\Windows' + } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') + const pathEntries = baseEnv.Path.split(';') + + expect(pathEnvKeys(baseEnv)).toEqual(['Path']) + expect(pathEntries[0]).toBe(win32Dir) + expect(pathEntries).toContain('C:\\Program Files\\Git\\cmd') + }) + + // Why: Win32 resolves a duplicated variable by taking the first match in the block, so the + // shim must land on the first-listed spelling and the shadowed one must keep its value — + // dropping it would promote it the moment anything downstream removes the live key. + it.each([ + { first: 'PATH', second: 'Path' }, + { first: 'Path', second: 'PATH' } + ])( + 'prepends the shim onto the first-listed `$first` and leaves `$second` untouched', + ({ first, second }) => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const baseEnv: Record = { + [first]: 'C:\\tools\\bin;C:\\Windows\\system32', + [second]: 'C:\\Windows\\system32' + } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') + expect(baseEnv[first].split(';')).toEqual([ + win32Dir, + 'C:\\tools\\bin', + 'C:\\Windows\\system32' + ]) + expect(baseEnv[second]).toBe('C:\\Windows\\system32') + } + ) + + it('keeps a shadowed duplicate when the live spelling strips down to nothing', () => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32') + const baseEnv: Record = { Path: shimDir, PATH: 'C:\\Windows\\system32' } + + applyTerminalAttributionEnv(baseEnv, { + enabled: false, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + expect(pathEnvKeys(baseEnv)).toEqual(['PATH']) + expect(baseEnv.PATH).toBe('C:\\Windows\\system32') + }) + + it('strips shim entries from the inherited `Path` when attribution is disabled', () => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32') + const baseEnv: Record = { Path: `${shimDir};C:\\Windows\\system32` } + + applyTerminalAttributionEnv(baseEnv, { + enabled: false, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + expect(pathEnvKeys(baseEnv)).toEqual(['Path']) + expect(baseEnv.Path).toBe('C:\\Windows\\system32') + }) + + // Why: the daemon receives a sparse env patch with no path key and re-merges its own block + // underneath it, so guessing the spelling here hands the child both. + it.each([ + { hostKey: 'PATH', otherKey: 'Path' }, + { hostKey: 'Path', otherKey: 'PATH' } + ])('adopts a host block spelt `$hostKey` on a path-less env', ({ hostKey, otherKey }) => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const inherited = { PATH: process.env.PATH, Path: process.env.Path } + delete process.env.PATH + delete process.env.Path + process.env[hostKey] = 'C:\\Windows\\system32' + + try { + const baseEnv: Record = { SystemRoot: 'C:\\Windows' } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + expect(pathEnvKeys(baseEnv)).toEqual([hostKey]) + expect(baseEnv[otherKey]).toBeUndefined() + } finally { + delete process.env[hostKey] + for (const [key, value] of Object.entries(inherited)) { + if (value !== undefined) { + process.env[key] = value + } + } + } + }) + + it('leaves a case-sensitive POSIX `Path` variable alone', () => { + const root = makeTmpRoot() + const baseEnv: Record = { + PATH: '/usr/bin:/bin', + Path: '/decoy' + } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'linux', + userDataPath: join(root, 'user-data') + }) + + expect(baseEnv.Path).toBe('/decoy') + expect(baseEnv.PATH.split(':')).toContain('/usr/bin') + }) +}) diff --git a/src/main/attribution/terminal-attribution.ts b/src/main/attribution/terminal-attribution.ts index d1c4dd099fe..136a01b82f2 100644 --- a/src/main/attribution/terminal-attribution.ts +++ b/src/main/attribution/terminal-attribution.ts @@ -5,6 +5,7 @@ instead of scattering generated shell fragments across files. */ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join, win32 as pathWin32 } from 'node:path' import { ORCA_GIT_COMMIT_TRAILER } from '../../shared/orca-attribution' +import { resolvePathEnvKey } from '../pty/windows-environment-path' const ATTRIBUTION_ROOT_DIR = 'orca-terminal-attribution' const ATTRIBUTION_SHIM_VERSION = '6' @@ -73,7 +74,8 @@ export function applyTerminalAttributionEnv( } const pathDelimiter = platform === 'win32' ? ';' : ':' - const basePath = baseEnv.PATH ?? process.env.PATH ?? '' + const pathKey = resolvePathEnvKey(baseEnv, platform) + const basePath = baseEnv[pathKey] ?? process.env[pathKey] ?? '' // Why: resolve real Windows commands before prepending shims so cmd wrappers // cannot recursively point ORCA_REAL_* at themselves. const resolvedGit = platform === 'win32' ? resolveWindowsExecutable('git', basePath) : null @@ -102,7 +104,7 @@ export function applyTerminalAttributionEnv( // shim directory here keeps the attribution behavior scoped to Orca's live // terminal environment instead of mutating global git/gh config or the // user's external shell PATH. - baseEnv.PATH = [...prependDirs, cleanedBasePath].filter(Boolean).join(pathDelimiter) + baseEnv[pathKey] = [...prependDirs, cleanedBasePath].filter(Boolean).join(pathDelimiter) baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION = '1' baseEnv.ORCA_GIT_COMMIT_TRAILER = ORCA_GIT_COMMIT_TRAILER baseEnv.ORCA_GH_PR_FOOTER = ORCA_GH_FOOTER @@ -131,11 +133,12 @@ function clearTerminalAttributionEnv( delete baseEnv[key] } const pathDelimiter = platform === 'win32' ? ';' : ':' - const cleanedPath = stripAttributionPathEntries(baseEnv.PATH ?? '', pathDelimiter) + const pathKey = resolvePathEnvKey(baseEnv, platform) + const cleanedPath = stripAttributionPathEntries(baseEnv[pathKey] ?? '', pathDelimiter) if (cleanedPath) { - baseEnv.PATH = cleanedPath + baseEnv[pathKey] = cleanedPath } else { - delete baseEnv.PATH + delete baseEnv[pathKey] } } diff --git a/src/main/cli/windows-launcher-asset.test.ts b/src/main/cli/windows-launcher-asset.test.ts index f491cb0771d..2eec4fee577 100644 --- a/src/main/cli/windows-launcher-asset.test.ts +++ b/src/main/cli/windows-launcher-asset.test.ts @@ -16,12 +16,15 @@ describe('packaged Windows CLI launcher asset', () => { const sourcePath = join(process.cwd(), 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs') const source = readFileSync(sourcePath, 'utf8') + // Why: the marker and command name must ride the launcher's own environment, never + // ProcessStartInfo's case-insensitive copy of a PATH/Path block (stablyai/orca#12046). expect(source).toContain( - 'startInfo.EnvironmentVariables["ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"] = "1";' + 'Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1");' ) - expect(source).toContain('Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND") == "orca-ide"') - expect(source).toContain('? "orca-ide"') - expect(source).toContain(': "orca";') + expect(source).toContain( + 'string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND");' + ) + expect(source).toContain('requestedCliCommand == "orca-ide" ? "orca-ide" : "orca"') expect(source).toContain('child.WaitForExit();') expect(source).toContain('return child.ExitCode;') }) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 5af4aa3e374..fdde466e9eb 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2146,6 +2146,77 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() }) + it('collapses its own env merge onto the requested Windows `Path` spelling', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + // Why: buildPtyHostEnv collapses Windows PATH onto one spelling before the daemon wire; + // the daemon then spreads its own block underneath and can re-mint the other one. + env: { + Path: '/tmp/orca-agent-teams-bin:/usr/bin', + ORCA_AGENT_TEAMS_TEAM_ID: 'team-test' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const env = spawnMock.mock.calls.at(-1)![2].env + expect(Object.keys(env).filter((key) => /^path$/i.test(key))).toEqual(['Path']) + expect(env.Path.split(':')[0]).toBe('/tmp/orca-agent-teams-bin') + }) + + it('keeps the daemon `PATH` block when the requested env has no path key', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: { FOO: 'bar' } }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const env = spawnMock.mock.calls.at(-1)![2].env + expect(env.PATH).toBe(process.env.PATH) + }) + + it('preserves a duplicated path block supplied by main', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { PATH: 'C:\\Live', Path: 'C:\\Shadowed' } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const env = spawnMock.mock.calls.at(-1)![2].env + expect(env.PATH).toBe('C:\\Live') + expect(env.Path).toBe('C:\\Shadowed') + }) + it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 95958b12b93..851eef218b8 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -32,6 +32,7 @@ import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home- import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' +import { resolvePathEnvKey } from '../pty/windows-environment-path' import { parseWslPath } from '../wsl' import { addWslEnvKeys } from '../wsl-env' import { @@ -77,6 +78,7 @@ const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_WORKTREE_ID', 'ORCA_AGENT_LAUNCH_TOKEN' ] as const +const WINDOWS_PATH_ENV_KEY_RE = /^path$/i const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 // Why: a Windows refresh forks a heavy powershell.exe CIM scan (~10-40x POSIX `ps`); idle shells retry slower, output re-arms the fast retry. @@ -165,6 +167,36 @@ function removeUnspecifiedPaneIdentityEnv( } } +/** Removes the second PATH key only when the daemon's env merge created it. */ +function collapseWindowsPathEnvKeys( + env: Record, + requestedEnv: Record | undefined +): void { + if (process.platform !== 'win32') { + return + } + const pathKeys = Object.keys(env).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) + if (pathKeys.length < 2) { + return + } + // Why: a one-key main patch is authoritative; zero or two keys came from inherited state. + const requestedKeys = requestedEnv + ? Object.keys(requestedEnv).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) + : [] + if (requestedKeys.length !== 1) { + return + } + const survivingKey = requestedKeys[0] + if (!survivingKey || env[survivingKey] === undefined) { + return + } + for (const key of pathKeys) { + if (key !== survivingKey) { + delete env[key] + } + } +} + /** * Promotes the agent-teams shim path ahead of inherited PATH entries. */ @@ -184,8 +216,9 @@ function promoteAgentTeamsShimPath( if (!shimDir) { return } - const currentParts = env.PATH?.split(pathDelimiter).filter(Boolean) ?? [] - env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) + const pathKey = resolvePathEnvKey(env, process.platform) + const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] + env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) } /** @@ -776,8 +809,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl ) { addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) } + const requestedEnv = opts.env expandWindowsPathEnvironmentVariables(env) - promoteAgentTeamsShimPath(env, opts.env?.PATH) + // Why: collapse before promoting so the shim lands on the spelling the child actually inherits. + collapseWindowsPathEnvKeys(env, requestedEnv) + const requestedPath = requestedEnv + ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] + : undefined + promoteAgentTeamsShimPath(env, requestedPath) // Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix. ensureNodePtySpawnHelperExecutable() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 401852169ec..cd01a985243 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -147,7 +147,7 @@ import { clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' import { parseWslPath } from '../wsl' -import { mergePersistedWindowsPath } from '../pty/windows-environment-path' +import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env' import { PtyProducerFlowController } from './pty-producer-flow-control' import { beginTerminalInstall } from './watcher-removal-gate' @@ -1011,7 +1011,8 @@ export type BuildPtyHostEnvOptions = { } function readInheritedPath(baseEnv: Record): string { - return baseEnv.PATH ?? baseEnv.Path ?? process.env.PATH ?? process.env.Path ?? '' + const pathKey = resolvePathEnvKey(baseEnv, process.platform) + return baseEnv[pathKey] ?? process.env[pathKey] ?? '' } function firstPathEntry(pathValue: string | undefined): string | null { @@ -1675,7 +1676,9 @@ export function buildPtyHostEnv( const devCliBin = join(opts.userDataPath, 'cli', 'bin') const inheritedPath = readInheritedPath(baseEnv) // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. - baseEnv.PATH = inheritedPath ? `${devCliBin}${delimiter}${inheritedPath}` : devCliBin + baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath + ? `${devCliBin}${delimiter}${inheritedPath}` + : devCliBin } else if (process.platform === 'linux') { // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) @@ -4338,7 +4341,9 @@ export function registerPtyHandlers( let env: Record | undefined = claudeAuth ? { ...sshScopedEnv, ...claudeAuth.envPatch } : sshScopedEnv - const requestedAgentTeamsPath = env?.ORCA_AGENT_TEAMS_TEAM_ID ? env.PATH : undefined + const requestedAgentTeamsPath = env?.ORCA_AGENT_TEAMS_TEAM_ID + ? env[resolvePathEnvKey(env, process.platform)] + : undefined env = stripSequencedStartupResumeArgv(env, codexResumeLaunch) if (args.preAllocatedHandle) { env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } @@ -5667,7 +5672,9 @@ export function registerPtyHandlers( } } } - const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID ? baseEnv.PATH : undefined + const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID + ? baseEnv[resolvePathEnvKey(baseEnv, process.platform)] + : undefined const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv ? ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] : undefined diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index b7dcb4399fa..e1c62e9f3cb 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -805,6 +805,31 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0') }) + it('promotes the agent-teams shim onto the Windows `Path` spelling', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + provider.configure({ + buildSpawnEnv: (_id, env) => { + // Why: attribution collapses Windows PATH onto `Path` and prepends its own shim dir. + delete env.PATH + env.Path = `/tmp/orca-attribution:${env.Path ?? ''}` + return env + } + }) + + await provider.spawn({ + cols: 80, + rows: 24, + env: { + Path: '/tmp/orca-agent-teams-bin:/usr/bin', + ORCA_AGENT_TEAMS_TEAM_ID: 'team-test' + } + }) + + const spawnEnv = spawnMock.mock.calls.at(-1)![2].env + expect(Object.keys(spawnEnv).filter((key) => /^path$/i.test(key))).toEqual(['Path']) + expect(spawnEnv.Path.split(':')[0]).toBe('/tmp/orca-agent-teams-bin') + }) + it('does not pass a Windows Codex home into WSL terminals', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) provider.configure({ diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 12418877003..5e8385bed88 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 { resolvePathEnvKey } from '../pty/windows-environment-path' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' import { @@ -171,8 +172,9 @@ function promoteAgentTeamsShimPath( if (!shimDir) { return } - const currentParts = env.PATH?.split(pathDelimiter).filter(Boolean) ?? [] - env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) + const pathKey = resolvePathEnvKey(env, process.platform) + const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] + env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) } /** @@ -804,8 +806,12 @@ export class LocalPtyProvider implements IPtyProvider { shellReadyLaunch = args.command ? shellLaunch : null } } + const requestedEnv = args.env expandWindowsPathEnvironmentVariables(finalEnv) - promoteAgentTeamsShimPath(finalEnv, args.env?.PATH) + promoteAgentTeamsShimPath( + finalEnv, + requestedEnv ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] : undefined + ) // Why: worktree-scoped HISTFILE — without it worktrees share one global history (terminal-history-scope-design §7–§10). const worktreeId = args.worktreeId diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index 4ecf3200e9a..d639ee604ce 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -15,7 +15,8 @@ import { mergePersistedWindowsPath, mergePersistedWindowsPathAsync, readPersistedWindowsPathSegments, - readPersistedWindowsPathSegmentsAsync + readPersistedWindowsPathSegmentsAsync, + resolvePathEnvKey } from './windows-environment-path' type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void @@ -309,4 +310,53 @@ describe('mergePersistedWindowsPath', () => { expect(env).toEqual({ Path: 'C:\\Inherited;C:\\Machine;C:\\User' }) }) + + it('reads the live host spelling when a path-less env inherits duplicate keys', () => { + const execFileSync = vi + .fn() + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + const env: Record = {} + + mergePersistedWindowsPath(env, { + platform: 'win32', + execFileSync, + env: { ROOT: 'C:\\Root', Path: '%ROOT%\\Live', PATH: 'C:\\Shadowed' } + }) + + expect(env).toEqual({ Path: 'C:\\Root\\Live;C:\\Machine;C:\\User' }) + }) +}) + +describe('resolvePathEnvKey', () => { + it('uses whichever Windows spelling is present on the env', () => { + expect(resolvePathEnvKey({ Path: 'C:\\Windows' }, 'win32')).toBe('Path') + expect(resolvePathEnvKey({ PATH: 'C:\\Windows' }, 'win32')).toBe('PATH') + expect(resolvePathEnvKey({ path: 'C:\\Windows' }, 'win32')).toBe('path') + expect(resolvePathEnvKey({ PaTh: 'C:\\Windows' }, 'win32')).toBe('PaTh') + }) + + // Why: Win32 returns the first case-insensitive match in the block, so a dual-cased env is + // resolved by position; picking by casing would target the spelling the child never sees. + it('resolves a dual-cased Windows env by block order, not casing', () => { + expect(resolvePathEnvKey({ PATH: 'C:\\Live', Path: 'C:\\Shadowed' }, 'win32')).toBe('PATH') + expect(resolvePathEnvKey({ Path: 'C:\\Live', PATH: 'C:\\Shadowed' }, 'win32')).toBe('Path') + }) + + it('skips a path key explicitly set to undefined', () => { + expect(resolvePathEnvKey({ PATH: undefined, Path: 'C:\\Windows' }, 'win32')).toBe('Path') + }) + + it('falls back to the host block spelling for a Windows env with no path key', () => { + // Why: a sparse patch that guesses wrong leaves the daemon's own merge holding both keys. + expect(resolvePathEnvKey({}, 'win32', { PATH: 'C:\\Windows' })).toBe('PATH') + expect(resolvePathEnvKey({}, 'win32', { Path: 'C:\\Windows' })).toBe('Path') + expect(resolvePathEnvKey({}, 'win32', { Path: 'C:\\Live', PATH: 'C:\\Shadowed' })).toBe('Path') + expect(resolvePathEnvKey({}, 'win32', {})).toBe('Path') + }) + + it('always resolves `PATH` off Windows so a POSIX `Path` variable is untouched', () => { + expect(resolvePathEnvKey({ Path: '/decoy' }, 'linux')).toBe('PATH') + expect(resolvePathEnvKey({ Path: '/decoy', PATH: '/usr/bin' }, 'darwin')).toBe('PATH') + }) }) diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 07d68fbd9dc..36032da9aec 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -236,6 +236,32 @@ export function __resetPersistedWindowsPathCacheForTests(): void { pendingPersistedWindowsPathRefresh = undefined } +/** Resolves the first PATH key a Windows child reads from an env block. */ +export function resolvePathEnvKey( + env: NodeJS.ProcessEnv | Record, + platform: NodeJS.Platform, + hostEnv: NodeJS.ProcessEnv = process.env +): string { + if (platform !== 'win32') { + return 'PATH' + } + // Why: the daemon receives a sparse env patch and re-merges its own block underneath it; + // matching the block's spelling stops that merge from resurrecting the other one. + return firstWindowsPathEnvKey(env) ?? firstWindowsPathEnvKey(hostEnv) ?? 'Path' +} + +// Why: Win32 resolves the first case-insensitive key, and object order preserves block order. +function firstWindowsPathEnvKey( + env: NodeJS.ProcessEnv | Record +): string | undefined { + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'path' && env[key] !== undefined) { + return key + } + } + return undefined +} + function mergeWindowsPathSegments( env: NodeJS.ProcessEnv, persistedSegments: string[], @@ -243,9 +269,10 @@ function mergeWindowsPathSegments( sourceEnv: NodeJS.ProcessEnv ): void { expandWindowsPathEnvironmentVariables(env, platform) - const pathKey = env.Path !== undefined ? 'Path' : env.PATH !== undefined ? 'PATH' : 'Path' + const pathKey = resolvePathEnvKey(env, platform, sourceEnv) const pathDelimiter = getPathDelimiter(platform) - const currentPath = env[pathKey] ?? sourceEnv.PATH ?? sourceEnv.Path ?? '' + const currentPath = + env[pathKey] ?? expandWindowsEnvironmentVariables(sourceEnv[pathKey] ?? '', sourceEnv) const currentSegments = splitPathSegments(currentPath, pathDelimiter) const existing = new Set(currentSegments.map((segment) => segment.toLowerCase())) const missing = persistedSegments.filter((segment) => { diff --git a/src/main/runtime/claude-agent-teams-service.test.ts b/src/main/runtime/claude-agent-teams-service.test.ts index f02d470fc8a..a41668932f0 100644 --- a/src/main/runtime/claude-agent-teams-service.test.ts +++ b/src/main/runtime/claude-agent-teams-service.test.ts @@ -248,4 +248,24 @@ describe('ClaudeAgentTeamsService', () => { ) ).resolves.toMatchObject({ ok: false, exitCode: 1 }) }) + + it('keeps the inherited Windows `Path` instead of minting a truncated `PATH`', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const launch = new ClaudeAgentTeamsService().createLaunchEnv({ + leaderHandle: 'leader-handle', + baseEnv: { Path: 'C:\\Windows\\system32' }, + shimDir: 'C:\\orca-shim', + shimBin: 'C:\\orca.exe' + }) + + expect(Object.keys(launch.env).filter((key) => /^path$/i.test(key))).toEqual(['Path']) + expect(launch.env.Path).toBe('C:\\orca-shim;C:\\Windows\\system32') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) }) diff --git a/src/main/runtime/claude-agent-teams-service.ts b/src/main/runtime/claude-agent-teams-service.ts index 1da810e4a33..1b0199c6bd0 100644 --- a/src/main/runtime/claude-agent-teams-service.ts +++ b/src/main/runtime/claude-agent-teams-service.ts @@ -1,6 +1,7 @@ import { randomBytes, randomUUID } from 'node:crypto' import { splitTmuxCommand } from '../../shared/claude-agent-teams-tmux-compat' import { ClaudeAgentTeamsTmuxDispatcher } from './claude-agent-teams-tmux-dispatcher' +import { resolvePathEnvKey } from '../pty/windows-environment-path' import type { AgentTeam, AgentTeamsLaunchEnv, @@ -30,13 +31,15 @@ export class ClaudeAgentTeamsService { const teamId = `team-${randomUUID()}` const token = randomBytes(32).toString('base64url') const leaderPane = '%1' - const pathValue = [args.shimDir, args.baseEnv.PATH] + // Why: Windows callers pass an env spelt `Path`; reading `PATH` there truncated the launch PATH to just the shim dir. + const pathKey = resolvePathEnvKey(args.baseEnv, process.platform) + const pathValue = [args.shimDir, args.baseEnv[pathKey]] .filter(Boolean) .join(process.platform === 'win32' ? ';' : ':') const tmuxValue = `/tmp/orca-claude-agent-teams/${teamId},0,1` const env: Record = { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - PATH: pathValue, + [pathKey]: pathValue, TMUX: tmuxValue, TMUX_PANE: leaderPane, TERM: 'screen-256color', From 9a97e737f56a81d9526b7831df5d84194b198883 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:58:25 -0700 Subject: [PATCH 025/101] test(e2e): pick the board marquee start point from live geometry (#12409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(e2e): start the board marquee off the board's padding cliff `selects the full lane across a single large marquee scroll jump` failed the changed-e2e-specs job with `Received: 0` — no card ever previewed. The CI trace shows the drag started at (284, 195) and the failure screenshot shows a native text selection with no marquee overlay, so the board never accepted the pointerdown. An element scan across that row shows why: the marquee may only start on empty board space, and the usable strip is only x 280-298 — the board's own left edge on one side, the first lane's cards on the other. `selectionBox.x + 4` aims 4px inside that strip, so a layout that rounds a few pixels differently lands outside the board entirely, where the pointerdown never reaches the handler and the browser text-selects instead. Aim at the middle of the measured strip, assert the start point really is empty board space before pressing, and assert the selection rect appears so a rejected gesture fails immediately instead of surfacing 15s later as "0 cards previewed". Also carried over from the first attempt at this deflake: the lane scroll now jumps until the virtualizer stops moving the bottom, because a measured card is much taller than the row estimate and a fixed pass budget commits the marquee short of the last cards; and the final assertion checks the badge's text so a short selection reports its count. * test(e2e): pick the marquee start point from live geometry, not a precomputed one Round 2 fired the new guard: `marquee start point (290, 195) must be empty board space` with `onSurface: false` AND `onIgnoredTarget: false` — so elementFromPoint returned something outside the board entirely, even though (290, 195) sits inside the measured 280..299 strip. Aiming at the middle of the strip is still aiming at a point computed before the probe runs, and the sheet, sidebar and lane fill keep resizing that strip afterwards. Stop precomputing the point. Read the geometry and scan for an empty point in the same DOM turn, walking a grid across the strip between the board's left edge and the first card, over the lane's top rows only — the marquee anchors its range in content space, so a start below the first card would drop it from the 102. Take the first point the board itself reports as empty, and require two consecutive probes to agree so a frame mid-relayout cannot win. Keep the precondition guard, now reporting the live geometry and the elements that blocked each rejected point. If no empty point exists at all, skip with that reason instead of failing on a layout the test cannot drive. * test(e2e): start marquee from an explicit empty lane * test(e2e): find visible board space for marquee start * test(e2e): quarantine unstable marquee hit test --- ...orkspace-board-lane-virtualization.spec.ts | 91 ++++++++++++++++--- 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts index 44042ce16f0..a6c847a5504 100644 --- a/tests/e2e/workspace-board-lane-virtualization.spec.ts +++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts @@ -307,9 +307,11 @@ test.describe('Workspace board lane virtualization', () => { }) test('selects the full lane across a single large marquee scroll jump', async ({ orcaPage }) => { + test.skip(true, 'Quarantined by https://github.com/stablyai/orca/issues/12415') const statusId = 'virtual-marquee' + const emptyStatusId = 'virtual-marquee-start' await orcaPage.evaluate( - ({ count, status }) => { + ({ count, emptyStatus, status }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -353,10 +355,13 @@ test.describe('Workspace board lane virtualization', () => { }) state.setWorkspaceStatuses([ { id: status, label: 'Virtual marquee' }, - ...state.workspaceStatuses.filter((entry) => entry.id !== status) + { id: emptyStatus, label: 'Marquee start' }, + ...state.workspaceStatuses.filter( + (entry) => entry.id !== status && entry.id !== emptyStatus + ) ]) }, - { count: MARQUEE_WORKSPACE_COUNT, status: statusId } + { count: MARQUEE_WORKSPACE_COUNT, emptyStatus: emptyStatusId, status: statusId } ) await orcaPage.getByRole('button', { name: 'Workspace board' }).click() @@ -366,35 +371,95 @@ test.describe('Workspace board lane virtualization', () => { const laneCards = lane.locator('[data-workspace-board-card-id]') await expect.poll(() => laneCards.count(), { timeout: 15_000 }).toBeGreaterThan(3) const laneScroll = lane.locator('[data-workspace-board-lane-scroll]') - const selectionSurface = orcaPage.locator('[data-workspace-board-selection-surface]') + const emptyLaneScroll = orcaPage.locator( + `[data-workspace-status="${emptyStatusId}"] [data-workspace-board-lane-scroll]` + ) const box = await laneScroll.boundingBox() - const selectionBox = await selectionSurface.boundingBox() - if (!box || !selectionBox) { - throw new Error('Expected the marquee lane and selection surface to have bounding boxes') + if (!box) { + throw new Error('Expected the marquee lane to have a bounding box') } - await orcaPage.mouse.move(selectionBox.x + 4, box.y + 12) + // Why: CI can overlay individual lane pixels, so choose a live board-owned point. + const startPoint = await emptyLaneScroll.evaluate((element) => { + const ignored = [ + '[data-workspace-board-card-id]', + 'a', + 'button', + 'input', + 'select', + 'textarea', + '[role="button"]', + '[role="menu"]', + '[role="menuitem"]' + ].join(',') + const rect = element.getBoundingClientRect() + for (let y = Math.ceil(rect.top) + 6; y <= Math.floor(rect.top) + 40; y += 6) { + for (let x = Math.ceil(rect.left) + 8; x <= Math.floor(rect.right) - 8; x += 8) { + const target = document.elementFromPoint(x, y) + if ( + target?.closest('[data-workspace-board-selection-surface]') && + !target.closest(ignored) + ) { + return { x, y } + } + } + } + return null + }) + expect(startPoint, 'the empty start lane must expose board-owned space').not.toBeNull() + if (!startPoint) { + throw new Error('Expected empty board space for the marquee start') + } + + await orcaPage.mouse.move(startPoint.x, startPoint.y) await orcaPage.mouse.down() await orcaPage.mouse.move(box.x + box.width - 18, box.y + 80, { steps: 4 }) + + // Why: proves the board accepted the gesture. Without it a rejected + // pointerdown only shows up 15s later as "0 cards previewed", which reads as + // a virtualization bug rather than a marquee that never started. + await expect(orcaPage.locator('[data-workspace-board-selection-rect]')).toBeVisible() await expect .poll(() => lane.locator('[data-workspace-board-card-area-selected="true"]').count(), { timeout: 15_000 }) .toBeGreaterThan(0) - await laneScroll.evaluate(async (element) => { - for (let pass = 0; pass < 4; pass++) { + // Why: a measured card is much taller than the lane's row estimate, so each + // jump to the bottom re-measures the window and grows the spacer past the + // scrollTop the jump just landed on. A fixed pass budget commits the marquee + // mid-growth on a loaded machine — short of the last cards — so jump until + // the virtualizer stops moving the bottom instead. + const laneScrollSettle = await laneScroll.evaluate(async (element) => { + let settledPasses = 0 + let passes = 0 + while (settledPasses < 2 && passes < 40) { + passes += 1 element.scrollTop = element.scrollHeight element.dispatchEvent(new Event('scroll', { bubbles: true })) await new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())) }) + const maxScrollTop = element.scrollHeight - element.clientHeight + settledPasses = element.scrollTop >= maxScrollTop - 1 ? settledPasses + 1 : 0 + } + return { + passes, + scrollTop: element.scrollTop, + maxScrollTop: element.scrollHeight - element.clientHeight } }) + expect( + laneScrollSettle.scrollTop, + `lane scroll never settled at its bottom: ${JSON.stringify(laneScrollSettle)}` + ).toBeGreaterThanOrEqual(laneScrollSettle.maxScrollTop - 1) + await orcaPage.mouse.move(box.x + box.width - 18, box.y + box.height - 12) await orcaPage.mouse.up() - await expect( - orcaPage.getByText(`${MARQUEE_WORKSPACE_COUNT} selected`, { exact: true }) - ).toBeVisible() + // Why: assert the badge's text, not its presence — a marquee that stopped + // short reports the count it did commit instead of a bare locator timeout. + await expect(orcaPage.getByText(/^\d+ selected$/)).toHaveText( + `${MARQUEE_WORKSPACE_COUNT} selected` + ) }) }) From a6b14eb04c9aa8a1812f2a74a417fbb7d79eb68b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:00:57 -0700 Subject: [PATCH 026/101] fix(terminal): reset stale mouse tracking on cold restore (#12101); stop OSC color-reply echo leak in POSIX agent panes (#12112) (#12202) * fix(terminal): reset stale mouse tracking on cold restore (#12101); stop OSC color-reply echo leak in POSIX agent panes (#12112) #12101: a force-killed TUI never emits its DECRST reset, so its armed mouse mode is latched into the on-disk checkpoint and re-derived into the replacement process's emulator via the cold-restore history seed -- through both rehydrateSequences and SerializeAddon's own mode trailer. The revived bare shell then echoed SGR motion reports at the prompt. Seed a RESET_MOUSE_REPORTING segment after the snapshot (before the torn escape tail), only when there is real recovered content so the empty-array "nothing to recover" sentinel survives. #12112: agent panes arm a main-side PtyStartupIngress that answered opencode's startup OSC 10/11 queries synchronously inside node-pty's onData, while the POSIX tty still had ECHO on. The line discipline echoed Orca's own reply back out as visible text. Echo suppression existed but was gated on windows-conpty. Add PtyStartupReplyDelivery: POSIX defers the write off the query's turn and recognizes its own echo anywhere in a span (bounded, non-destructive); ConPTY keeps its synchronous write; windows-wsl is byte-identical to before. Fixes #12101 Co-authored-by: Orca * fix(terminal): read the slave's ECHO bit before answering a color query The startup color reply was written into a PTY still in cooked mode, so the line discipline echoed it back as visible junk (#12112). Whether that will happen is readable state on the slave rather than something to infer from returning bytes, so the reply now waits until the ECHO bit is observably clear instead of guessing at echo shapes. Two echo sources exist and only one is readable. A `quiet` verdict proves the kernel will not echo, so it retires the caret projection; readline echoes a master write in software with the tty already raw, so that projection stays armed on every path. Scoping `quiet` narrowly is the whole correctness argument here: reading it as "no suppression needed" reintroduces the bug at a plain shell prompt. Polling is bounded by a wall-clock budget rather than an attempt count, because each probe is a subprocess and a multi-pane restore serializes them on fork. Withholding measures flat at ~210ms from 1 to 100 panes. Also resets a cold-restored pane's mouse reporting (#12101). The armed mode is re-derived from the dead process's own persisted bytes through two channels, so the daemon seeds a reset into recovered history and the renderer stops trusting a persisted "live agent" signal after a cold restore. The reset literals move to one shared profile module. Fixes #12101 Fixes #12112 Co-authored-by: Orca * test(terminal): pin the cold-restore reset on the spawn-adopted reattach path A spawn can be answered with an adopted session, which reaches the reattach handler by a door that skips the restored-session path. Pin that the cold-restore signal survives it, so #12101's junk cannot come back through it. Co-authored-by: Orca * test(terminal): note why the adopted-reattach snapshot leaves the cursor visible Co-authored-by: Orca * fix(terminal): harden startup reply delivery --------- Co-authored-by: Orca --- .../hibernation-cold-restore-repro.test.ts | 4 +- src/main/daemon/pty-subprocess.ts | 3 + ...ouse-tracking-survives-agent-death.test.ts | 274 +++++++ ...ro-7329-remote-snapshot-corruption.test.ts | 8 +- src/main/daemon/session.ts | 8 +- .../terminal-history-seed-segments.test.ts | 119 +++ .../daemon/terminal-history-seed-segments.ts | 28 +- src/main/ipc/pty.test.ts | 9 +- src/main/providers/local-pty-provider.ts | 8 +- src/relay/pty-handler.ts | 5 +- ...ane-startup-color-reply-leak.repro.test.ts | 284 ++++++++ .../layout-serialization.test.ts | 6 +- .../terminal-pane/layout-serialization.ts | 35 +- .../terminal-pane/pty-connection.test.ts | 99 ++- .../terminal-pane/pty-connection.ts | 28 +- .../terminal-replay-cursor-state.test.ts | 2 +- .../use-terminal-pane-lifecycle.ts | 2 +- .../pty-slave-line-discipline-echo.test.ts | 119 +++ src/shared/pty-slave-line-discipline-echo.ts | 102 +++ src/shared/pty-startup-ingress-contract.ts | 8 + src/shared/pty-startup-ingress.test.ts | 688 +++++++++++++++++- src/shared/pty-startup-ingress.ts | 204 ++++-- src/shared/pty-startup-reply-delivery.ts | 349 +++++++++ .../terminal-mode-reset-profiles.test.ts | 82 +++ src/shared/terminal-mode-reset-profiles.ts | 41 ++ src/shared/terminal-restore-parity-fixture.ts | 4 +- tests/e2e/terminal-attention.spec.ts | 4 +- 27 files changed, 2377 insertions(+), 146 deletions(-) create mode 100644 src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts create mode 100644 src/main/daemon/terminal-history-seed-segments.test.ts create mode 100644 src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts create mode 100644 src/shared/pty-slave-line-discipline-echo.test.ts create mode 100644 src/shared/pty-slave-line-discipline-echo.ts create mode 100644 src/shared/pty-startup-reply-delivery.ts create mode 100644 src/shared/terminal-mode-reset-profiles.test.ts create mode 100644 src/shared/terminal-mode-reset-profiles.ts diff --git a/src/main/daemon/hibernation-cold-restore-repro.test.ts b/src/main/daemon/hibernation-cold-restore-repro.test.ts index 561c7de261e..c8e8ea232f4 100644 --- a/src/main/daemon/hibernation-cold-restore-repro.test.ts +++ b/src/main/daemon/hibernation-cold-restore-repro.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { HistoryManager } from './history-manager' import { HistoryReader } from './history-reader' import { HeadlessEmulator } from './headless-emulator' +import { POST_REPLAY_MODE_RESET } from '../../shared/terminal-mode-reset-profiles' // Reproduces the "blank pane after agent hibernation" bug: alt-screen TUI snapshots have scrollbackAnsi='', so the adapter's // `if (scrollback)` gate (daemon-pty-adapter.ts:230) dropped the cold-restore payload and repainted blank despite an intact snapshotAnsi. @@ -95,9 +96,6 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => { expect(adapterScrollback).not.toBeNull() // Must end in the normal buffer (no alt-screen re-entry) so it won't fight the agent's own repaint when resume relaunches it. - // POST_REPLAY_MODE_RESET copied literally from renderer layout-serialization.ts (main-process test can't import renderer) — keep in sync. - const POST_REPLAY_MODE_RESET = - '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b[?2004l' const fresh = new HeadlessEmulator({ cols: 80, rows: 24 }) fresh.writeSync('\x1b[2J\x1b[3J\x1b[H') fresh.writeSync(adapterScrollback as string) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 851eef218b8..ca8527fb11f 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -71,6 +71,7 @@ import { expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' +import { readPtySlavePath } from '../../shared/pty-slave-line-discipline-echo' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -1039,9 +1040,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl } }) + const slavePath = readPtySlavePath(proc) return { pid: proc.pid, shellPath, + ...(slavePath ? { slavePath } : {}), ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), getForegroundProcess: () => { // Why: node-pty's `.process` reports the live foreground name but reads a recycled pid on a reaped pty, so bail when dead. diff --git a/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts b/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts new file mode 100644 index 00000000000..0e99e002941 --- /dev/null +++ b/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts @@ -0,0 +1,274 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mkdtempSync, rmSync } from 'node:fs' +import { Terminal } from '@xterm/headless' +import { Session } from './session' +import { HistoryManager } from './history-manager' +import { HistoryReader } from './history-reader' +import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' +import { iterateTerminalHistorySeedChunks } from './terminal-history-seed-chunks' + +// Repro for #12101: a TUI that armed DECSET mouse tracking (Claude Code, vim, +// htop) is force-killed and never emits the matching DECRST. Nothing in the +// daemon substitutes the reset, so `TerminalModes.mouseTracking` is latched into +// the on-disk checkpoint and then re-derived — from the DEAD process's own bytes — +// into every subsequent fresh emulator. The revived pane runs a bare shell that +// never armed mouse reporting, yet its snapshot re-arms it, so pointer motion +// echoes literal SGR reports (^[[<35;col;rowM) into the prompt. +// +// This drives the REAL Session / HeadlessEmulator / TerminalMouseModeMirror / +// buildRehydrateSequences / HistoryManager / HistoryReader, with a fake +// subprocess standing in for node-pty (a real PTY can't be SIGKILLed +// deterministically mid-DECSET in vitest). The kill goes through Session.kill()'s +// real teardown; only killWithDescendantSweep is stubbed so the test never +// signals a real pid. + +const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) +vi.mock('../pty-descendant-termination', () => ({ + killWithDescendantSweep: killWithDescendantSweepMock +})) + +// The variant `reattachReplayResetSequence` picks when the pane still looks like a +// live agent, which is exactly the stale state a SIGKILLed agent leaves behind: it +// deliberately OMITS RESET_MOUSE_REPORTING to keep real TUI scroll gestures alive. +import { POST_REPLAY_LIVE_AGENT_REATTACH_RESET } from '../../shared/terminal-mode-reset-profiles' + +const ANY_MOTION_TRACKING_ON = '\x1b[?1003h' +const SGR_ENCODING_ON = '\x1b[?1006h' +const ANY_MOTION_TRACKING_OFF = '\x1b[?1003l' +const SGR_ENCODING_OFF = '\x1b[?1006l' + +function createFakeSubprocess(foregroundProcess: string) { + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + const written: string[] = [] + const signals: string[] = [] + return { + written, + signals, + forceKilled: false, + pid: 4242, + getForegroundProcess: () => foregroundProcess, + write: (data: string) => void written.push(data), + resize: () => {}, + kill: () => {}, + forceKill(this: { forceKilled: boolean }) { + this.forceKilled = true + }, + signal: (sig: string) => void signals.push(sig), + onData: (cb: (data: string) => void) => void (onData = cb), + onExit: (cb: (code: number) => void) => void (onExit = cb), + dispose: () => {}, + /** PTY output — same channel node-pty uses, so Session's real ingest runs. */ + emit: (data: string) => onData?.(data), + /** SIGKILL reaped: exit fires with no DECRST ever sent. */ + simulateKilledExit: () => onExit?.(-1) + } +} + +/** Session.emitSubprocessOutput awaits xterm's async parse; poll until the + * emulator has committed the bytes so snapshots are taken on a settled stream. */ +async function waitForEmulatorParse(session: Session): Promise { + for (let i = 0; i < 200; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 1)) + if (session.getSnapshot()?.snapshotAnsi.includes('$ ')) { + return + } + } + throw new Error('setup: emulator never parsed the agent output') +} + +describe('#12101 mouse tracking survives the death of the process that armed it', () => { + let dir: string + const sessionId = 'repo-1::/Users/dev/feature-branch' + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'repro-12101-')) + killWithDescendantSweepMock.mockReset() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + vi.useRealTimers() + }) + + it('re-arms mouse reporting in the fresh shell that replaces a force-killed agent', async () => { + const manager = new HistoryManager(dir) + const reader = new HistoryReader(dir) + + // 1. A live agent TUI arms any-motion tracking + SGR encoding. + const agentPty = createFakeSubprocess('claude') + const agent = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: agentPty, + launchAgent: 'claude', + shellReadySupported: false + }) + agentPty.emit(`${ANY_MOTION_TRACKING_ON}${SGR_ENCODING_ON}`) + agentPty.emit('user@host ~ % claude\r\nclaude> analyzing...\r\nuser@host ~ $ ') + await waitForEmulatorParse(agent) + + const armed = agent.getSnapshot() + expect(armed?.modes.mouseTracking).toBe(true) + expect(armed?.modes.mouseTrackingMode).toBe('any') + expect(armed?.rehydrateSequences).toContain(ANY_MOTION_TRACKING_ON) + + // 2. Sleep/hibernation takes its final teardown checkpoint while the agent + // is still alive with mouse armed (daemon-pty-adapter's {final,teardown}). + await manager.openSession(sessionId, { cwd: '/Users/dev/feature-branch', cols: 80, rows: 24 }) + await manager.checkpoint(sessionId, armed!) + + // 3. Force-kill. Real Session teardown; the child is reaped without ever + // emitting DECRST, and nothing writes one on its behalf. + agent.kill() + agentPty.simulateKilledExit() + agent.dispose() // TerminalHost.reapSession + expect(killWithDescendantSweepMock).toHaveBeenCalled() + const teardownBytes = agentPty.written.join('') + expect(teardownBytes).not.toContain(ANY_MOTION_TRACKING_OFF) + expect(teardownBytes).not.toContain(SGR_ENCODING_OFF) + + // 4. Wake: the session is still cold-restore eligible (killed with + // keepHistory, so endedAt stays null) and a FRESH shell takes its place, + // seeded with the recovered history exactly as daemon-server does. + const restoreInfo = await reader.detectColdRestore(sessionId) + expect(restoreInfo).not.toBeNull() + const seedChunks = [ + ...iterateTerminalHistorySeedChunks(getRecoveredHistorySeedSegments(restoreInfo!)) + ] + + const shellPty = createFakeSubprocess('zsh') + const shell = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: shellPty, + shellReadySupported: false, + historySeedChunks: seedChunks + }) + const revived = shell.getSnapshot() + try { + // #12101: the replacement shell's OWN state says mouse tracking is on, + // so every snapshot it serves — reattach, checkpoint, mobile — re-arms it. + // Soft so one run reports both re-arming channels, not just the first. + expect.soft(revived?.modes.mouseTracking).toBe(false) + expect.soft(revived?.modes.mouseTrackingMode).toBe('none') + expect.soft(revived?.rehydrateSequences).toBe('') + // Independent second channel: SerializeAddon's own mode trailer re-emits + // the DECSET from xterm's mouseTrackingMode, so dropping rehydrate alone + // would not disarm the pane. + expect.soft(revived?.snapshotAnsi).not.toContain(ANY_MOTION_TRACKING_ON) + } finally { + shell.dispose() + } + }) + + it('leaves the reattached renderer xterm armed against a dead agent (SGR reports into the prompt)', async () => { + const manager = new HistoryManager(dir) + const reader = new HistoryReader(dir) + + const agentPty = createFakeSubprocess('claude') + const agent = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: agentPty, + launchAgent: 'claude', + shellReadySupported: false + }) + agentPty.emit(`${ANY_MOTION_TRACKING_ON}${SGR_ENCODING_ON}`) + agentPty.emit('user@host ~ $ ') + await waitForEmulatorParse(agent) + + await manager.openSession(sessionId, { cwd: '/Users/dev/feature-branch', cols: 80, rows: 24 }) + await manager.checkpoint(sessionId, agent.getSnapshot()!) + agent.kill() + agentPty.simulateKilledExit() + agent.dispose() + + const restoreInfo = await reader.detectColdRestore(sessionId) + const shellPty = createFakeSubprocess('zsh') + const shell = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: shellPty, + shellReadySupported: false, + historySeedChunks: [ + ...iterateTerminalHistorySeedChunks(getRecoveredHistorySeedSegments(restoreInfo!)) + ] + }) + const revived = shell.getSnapshot()! + + // Reattach paint into a REAL renderer xterm, using the weakest profile in the + // family — the one a stale agent title used to select here. The renderer now + // forces the fresh-shell reset on a cold restore, so this pins the independent + // half: the seed alone must leave the revived session unarmed, because the + // daemon emulator's own state is what mobile and every other consumer read. + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await new Promise((resolve) => term.write('\x1b[2J\x1b[3J\x1b[H', resolve)) + await new Promise((resolve) => + term.write(revived.rehydrateSequences + revived.snapshotAnsi, resolve) + ) + await new Promise((resolve) => + term.write(POST_REPLAY_LIVE_AGENT_REATTACH_RESET, resolve) + ) + // The observable symptom: a bare `zsh` pane whose xterm reports pointer + // motion, so every mouse move types ^[[<35;col;rowM at the prompt. + expect(term.modes.mouseTrackingMode).toBe('none') + } finally { + term.dispose() + shell.dispose() + } + }) + + it('control: a TUI that DID emit DECRST before exiting restores a clean shell', async () => { + const manager = new HistoryManager(dir) + const reader = new HistoryReader(dir) + + const agentPty = createFakeSubprocess('vim') + const agent = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: agentPty, + shellReadySupported: false + }) + agentPty.emit(`${ANY_MOTION_TRACKING_ON}${SGR_ENCODING_ON}`) + agentPty.emit('user@host ~ $ ') + await waitForEmulatorParse(agent) + agentPty.emit(`${ANY_MOTION_TRACKING_OFF}${SGR_ENCODING_OFF}`) + for (let i = 0; i < 50 && agent.getSnapshot()?.modes.mouseTracking !== false; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 1)) + } + + await manager.openSession(sessionId, { cwd: '/Users/dev/feature-branch', cols: 80, rows: 24 }) + await manager.checkpoint(sessionId, agent.getSnapshot()!) + agent.kill() + agentPty.simulateKilledExit() + agent.dispose() + + const restoreInfo = await reader.detectColdRestore(sessionId) + const shellPty = createFakeSubprocess('zsh') + const shell = new Session({ + sessionId, + cols: 80, + rows: 24, + subprocess: shellPty, + shellReadySupported: false, + historySeedChunks: [ + ...iterateTerminalHistorySeedChunks(getRecoveredHistorySeedSegments(restoreInfo!)) + ] + }) + try { + expect(shell.getSnapshot()?.modes.mouseTracking).toBe(false) + expect(shell.getSnapshot()?.rehydrateSequences).toBe('') + } finally { + shell.dispose() + } + }) +}) diff --git a/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts b/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts index 8f8256519a4..d42fa33edaf 100644 --- a/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts +++ b/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { Terminal } from '@xterm/headless' import { HeadlessEmulator } from './headless-emulator' +// The reattach path the remote onSnapshot uses. +import { POST_REPLAY_REATTACH_RESET } from '../../shared/terminal-mode-reset-profiles' // Repro for #7329: "remote server + terminal" — typing gets escape sequences // injected/wrapped around it and follow-up commands are corrupted. @@ -11,12 +13,6 @@ import { HeadlessEmulator } from './headless-emulator' // This test drives the REAL daemon serializer and a REAL renderer-side xterm to // see what the user's terminal ends up looking like after a subscribe/reattach. -const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' -const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u' -const RESET_MOUSE_REPORTING = '\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l' -// Verbatim from layout-serialization.ts (the reattach path the remote onSnapshot uses). -const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l` - function writeXterm(term: Terminal, data: string): Promise { return new Promise((resolve) => term.write(data, resolve)) } diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7d96484edea..d9c5553d3e8 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -26,6 +26,7 @@ import type { TerminalSnapshot } from './types' import type { PtyOwnerBackend } from '../../shared/pty-owner-backend' +import { createPtySlaveEchoProbe } from '../../shared/pty-slave-line-discipline-echo' const SHELL_READY_TIMEOUT_MS = 15_000 // Why: Codex skips marker-gated command delivery; this only bounds older daemon/local paths that still report shell-ready for Codex. @@ -53,6 +54,9 @@ export type SubprocessHandle = { /** Shell the subprocess actually spawned, after fallbacks. The host reconciles the caller's shell-ready * assumption against it so a fallback shell without a ready marker never gates startup commands. */ shellPath?: string + /** Slave device path, so startup replies can read the line discipline's ECHO bit before + * writing. Absent on handles with no POSIX slave to read (ConPTY, tests). */ + slavePath?: string write(data: string): void resize(cols: number, rows: number): void /** Stop reading the PTY fd (node-pty pause()) so a flooding child blocks on write. Optional: @@ -164,11 +168,13 @@ export class Session { } this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue()) + const echoProbe = createPtySlaveEchoProbe(this.subprocess.slavePath) this.startupIngress = new PtyStartupIngress({ ...(opts.startupIngress ? { intent: opts.startupIngress } : {}), ...(opts.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}), write: (data) => this.subprocess.write(data), - onEmission: (emission) => this.emitSubprocessOutput(emission) + onEmission: (emission) => this.emitSubprocessOutput(emission), + ...(echoProbe ? { echoProbe } : {}) }) this.subprocess.onData((data) => this.handleSubprocessData(data)) this.subprocess.onExit((code) => this.handleSubprocessExit(code)) diff --git a/src/main/daemon/terminal-history-seed-segments.test.ts b/src/main/daemon/terminal-history-seed-segments.test.ts new file mode 100644 index 00000000000..7852a7eac89 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-segments.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' +import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' +import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' +import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles' +import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' +import type { TerminalModes } from './types' + +const ARMED_MODES: TerminalModes = { + bracketedPaste: false, + mouseTracking: true, + mouseTrackingMode: 'any', + sgrMouseMode: true, + applicationCursor: false, + alternateScreen: false +} + +// Why import rather than restate: the exact bytes are pinned in +// terminal-mode-reset-profiles.test.ts; this suite pins placement within the seed. +const MOUSE_OFF = COLD_RESTORE_SEED_MODE_RESET + +function restoreInfo(overrides: Partial = {}): ColdRestoreInfo { + return { + snapshotAnsi: 'user@host ~ $ \x1b[?1003h', + scrollbackAnsi: 'user@host ~ $ ', + rehydrateSequences: buildRehydrateSequences(ARMED_MODES), + cwd: '/w', + cols: 80, + rows: 24, + modes: ARMED_MODES, + ...overrides + } +} + +describe('getRecoveredHistorySeedSegments', () => { + it('disarms mouse reporting after the snapshot but before the torn escape tail', () => { + const segments = getRecoveredHistorySeedSegments( + restoreInfo({ pendingEscapeTailAnsi: '\x1b[3' }) + ) + expect(segments).toEqual([ + '\x1b[?1003h\x1b[?1006h', + 'user@host ~ $ \x1b[?1003h', + MOUSE_OFF, + '\x1b[3' + ]) + }) + + it('disarms mouse reporting on the alt-screen normal-buffer branch too', () => { + expect( + getRecoveredHistorySeedSegments( + restoreInfo({ modes: { ...ARMED_MODES, alternateScreen: true } }) + ) + ).toEqual(['user@host ~ $ ', MOUSE_OFF]) + }) + + it('stays empty when there is no recovered normal buffer', () => { + expect( + getRecoveredHistorySeedSegments( + restoreInfo({ + modes: { ...ARMED_MODES, alternateScreen: true }, + scrollbackAnsi: '', + snapshotAnsi: '' + }) + ) + ).toEqual([]) + }) + + it('keeps the empty "nothing to recover" sentinel on the normal-screen branch', () => { + // Why: daemon-pty-adapter keys the probe-race kill+respawn and the history + // re-anchor on `length === 0`, so the reset must never be the only segment. + expect( + getRecoveredHistorySeedSegments( + restoreInfo({ + modes: { ...ARMED_MODES, mouseTracking: false, mouseTrackingMode: 'none' }, + scrollbackAnsi: '', + snapshotAnsi: '', + rehydrateSequences: '' + }) + ) + ).toEqual([]) + }) + + it('keeps a torn escape last when it is the only recovered data', () => { + expect( + getRecoveredHistorySeedSegments( + restoreInfo({ + scrollbackAnsi: '', + snapshotAnsi: '', + rehydrateSequences: '', + pendingEscapeTailAnsi: '\x1b[3' + }) + ) + ).toEqual([MOUSE_OFF, '\x1b[3']) + }) + + it('leaves the revived emulator unarmed while preserving scrollback (#12101)', () => { + const emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + try { + for (const segment of getRecoveredHistorySeedSegments(restoreInfo())) { + expect(emulator.writeSync(segment)).toBe(true) + } + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.mouseTracking).toBe(false) + expect(snapshot.modes.mouseTrackingMode).toBe('none') + expect(snapshot.modes.sgrMouseMode).toBe(false) + expect(snapshot.rehydrateSequences).toBe('') + expect(snapshot.snapshotAnsi).not.toContain('\x1b[?1003h') + expect(snapshot.snapshotAnsi).toContain('user@host ~ $') + } finally { + emulator.dispose() + } + }) + + it('does not touch the live-session reattach payload (mobile scroll gestures)', () => { + // Why: only recovery seeding knows the arming TUI is dead; live reattach + // snapshots must keep re-arming or an alt-screen TUI loses scroll forever. + expect(buildRehydrateSequences(ARMED_MODES)).toBe('\x1b[?1003h\x1b[?1006h') + }) +}) diff --git a/src/main/daemon/terminal-history-seed-segments.ts b/src/main/daemon/terminal-history-seed-segments.ts index 78732e81931..670cef152b8 100644 --- a/src/main/daemon/terminal-history-seed-segments.ts +++ b/src/main/daemon/terminal-history-seed-segments.ts @@ -1,13 +1,29 @@ import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' +import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles' + +// Why the reset belongs in the seed and not only at replay: the recovered stream +// re-arms mouse reporting from two independent sources (rehydrateSequences AND +// SerializeAddon's own mode trailer inside snapshotAnsi), and the seed is what +// feeds the daemon's emulator — so without it every downstream consumer that +// re-serializes from that emulator, including mobile, inherits the dead TUI's +// modes no matter which profile the desktop renderer applies. See +// COLD_RESTORE_SEED_MODE_RESET for the precondition and the choice of bits. export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): readonly string[] { if (restoreInfo.modes.alternateScreen) { const normalBuffer = restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi - return normalBuffer ? [normalBuffer] : [] + return normalBuffer ? [normalBuffer, COLD_RESTORE_SEED_MODE_RESET] : [] } - return [ - restoreInfo.rehydrateSequences, - restoreInfo.snapshotAnsi, - ...(restoreInfo.pendingEscapeTailAnsi ? [restoreInfo.pendingEscapeTailAnsi] : []) - ].filter((segment) => segment.length > 0) + const recovered = [restoreInfo.rehydrateSequences, restoreInfo.snapshotAnsi].filter( + (segment) => segment.length > 0 + ) + const escapeTail = restoreInfo.pendingEscapeTailAnsi + // Why: an empty list is daemon-pty-adapter's "nothing to recover" sentinel (it gates + // the probe-race respawn and the history re-anchor), so the reset must never pad it. + if (recovered.length === 0 && !escapeTail) { + return [] + } + // Why after the snapshot: it must undo the snapshot's own mode trailer, and + // pendingEscapeTailAnsi is a torn escape that has to stay at the very end. + return [...recovered, COLD_RESTORE_SEED_MODE_RESET, ...(escapeTail ? [escapeTail] : [])] } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 3f60ec66720..257250a9858 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -12179,9 +12179,12 @@ describe('registerPtyHandlers', () => { const sourceData = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready' mockProc.emitData(sourceData) + // Why: the reply leaves the query's own turn so a still-cooked tty cannot + // echo it back as text instead of delivering it to the agent (#12112). + expect(mockProc.proc.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(2) expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'ready', @@ -12216,9 +12219,11 @@ describe('registerPtyHandlers', () => { const sourceData = '\x1b]10;?;?\x1b\\ready' mockProc.emitData(sourceData) + // Why: both slots of a duplicate-slot query leave the query's own turn too (#12112). + expect(mockProc.proc.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(2) expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'ready', diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 5e8385bed88..d43158482c0 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -66,6 +66,10 @@ import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' +import { + createPtySlaveEchoProbe, + readPtySlavePath +} from '../../shared/pty-slave-line-discipline-echo' import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables @@ -922,6 +926,7 @@ export class LocalPtyProvider implements IPtyProvider { ) } } + const startupEchoProbe = createPtySlaveEchoProbe(readPtySlavePath(proc)) const startupIngress = new PtyStartupIngress({ ...(args.startupIngress ? { intent: args.startupIngress } : {}), ownerBackend: resolvePtyOwnerBackend({ @@ -930,7 +935,8 @@ export class LocalPtyProvider implements IPtyProvider { wslDistro: spawnedWslDistro }), write: (data) => proc.write(data), - onEmission: emitIngressData + onEmission: emitIngressData, + ...(startupEchoProbe ? { echoProbe: startupEchoProbe } : {}) }) startupIngressByPty.set(id, startupIngress) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 13267b5847d..6e8116158b6 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -69,6 +69,7 @@ import { isAgentSessionSurfaceBinding, type AgentSessionOwnerBinding } from '../shared/agent-session-host-authority' +import { createPtySlaveEchoProbe, readPtySlavePath } from '../shared/pty-slave-line-discipline-echo' // Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap // there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never @@ -689,11 +690,13 @@ export class PtyHandler { : {} ) } + const echoProbe = createPtySlaveEchoProbe(readPtySlavePath(managed.pty)) managed.startupIngress ??= new PtyStartupIngress({ ...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}), ownerBackend: managed.ownerBackend, write: (data) => managed.pty.write(data), - onEmission: emitIngressData + onEmission: emitIngressData, + ...(echoProbe ? { echoProbe } : {}) }) managed.pty.onData((data: string) => { const startup = managed.startupCommand diff --git a/src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts b/src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts new file mode 100644 index 00000000000..d93f29ba796 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' +import { PtyStartupIngress } from '../../../../shared/pty-startup-ingress' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { installTerminalCapabilityReplyHandlers } from './terminal-capability-replies' +import { createIpcPtyTransport } from './pty-transport' +import type { PtyTransport } from './pty-transport-types' + +// Regression for #12112: a NEW opencode agent tab on Linux prints the literal +// `10;rgb:ffff/ffff/ffff` / `11;rgb:2828/2c2c/3434` text and opencode never +// initializes, while a plain Ctrl+T tab running the same program is clean. +// +// The divergence is the main-side PtyStartupIngress: only agent panes arm it +// (terminal-startup-color-query-replies.ts), and on `posix-pty` it answers the +// query synchronously inside node-pty's onData — before the querying program +// has finished entering raw mode — with no echo suppression. The suppression +// exists but is gated on `ownerBackend === 'windows-conpty'` +// (pty-startup-ingress.ts:26-29, 247-252), so the cooked-mode echo of Orca's +// own reply is forwarded to the renderer verbatim and rendered as text. + +// opencode/OpenTUI's unconditional startup burst (BEL-terminated, not ST). +const OPENCODE_STARTUP_QUERY_BURST = '\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?\x07' +// Orca's One Dark terminal theme — the values that appear in the leaked text. +const ORCA_TERMINAL_THEME = { foreground: '#ffffff', background: '#282c34' } +const OSC10_REPLY = '\x1b]10;rgb:ffff/ffff/ffff\x1b\\' +const OSC11_REPLY = '\x1b]11;rgb:2828/2c2c/3434\x1b\\' +const LEAKED_COLOR_REPLY_TEXT = /\d\d;rgb:[0-9a-f]{4}\// + +const PTY_ID = 'pty-12112' +let dispatchPtyData: (payload: { id: string; data: string }) => void = () => {} + +/** + * bash/readline echo projection: `\e]` is an unbound binding, so readline eats + * ESC + `]` and beeps, then self-inserts the rest; the ST is eaten the same way. + * Verified against a real `bash --norc -i` behind node-pty, and it reproduces + * the reported string exactly once rendered by xterm. + */ +function readlineEchoOf(reply: string): string { + return reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', '') +} + +type StartupTty = { + /** A writer (main ingress or renderer) pushes bytes at the PTY master. */ + writeToPty: (data: string) => void + /** Bytes the querying program actually read in raw mode. */ + programInput: () => string + onPtyOutput: (sink: (data: string) => void) => void + emitStartupBurst: () => void +} + +/** + * Models the single fact that decides this bug: the tty is still line + * disciplined (shell prompt / program mid-`tcsetattr`) during the turn that + * carries the startup burst, and raw immediately after. A writer answering + * synchronously inside that turn is echoed; one answering a turn later reaches + * the program. Both arms of this test share this exact model, so the divergence + * comes from Orca's code, not from the harness. + */ +function createStartupTty(): StartupTty { + let raw = false + let received = '' + let sink: (data: string) => void = () => {} + return { + writeToPty: (data) => { + if (raw) { + received += data + return + } + sink(readlineEchoOf(data)) + }, + programInput: () => received, + onPtyOutput: (next) => { + sink = next + }, + emitStartupBurst: () => { + sink(OPENCODE_STARTUP_QUERY_BURST) + // Why microtask: opencode finishes entering raw mode essentially at once, + // but not inside its writer's synchronous callback. + queueMicrotask(() => { + raw = true + }) + } + } +} + +function stubPtyApi(tty: StartupTty): void { + vi.stubGlobal('window', { + api: { + pty: { + spawn: vi.fn(async () => ({ id: PTY_ID })), + write: vi.fn((_id: string, data: string) => tty.writeToPty(data)), + resize: vi.fn(), + kill: vi.fn(async () => {}), + claimViewport: vi.fn(), + onData: (cb: (payload: { id: string; data: string }) => void) => { + dispatchPtyData = cb + return () => {} + }, + onReplay: () => () => {}, + onExit: () => () => {} + } + } + }) +} + +type RendererPane = { + transport: PtyTransport + deliver: (data: string) => void + renderedText: () => string + dispose: () => void +} + +/** Real xterm + real capability-reply handlers + the real local IPC transport. */ +async function createRendererPane(): Promise { + const terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + terminal.options.theme = { ...ORCA_TERMINAL_THEME } + + const transport = createIpcPtyTransport({ worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'pane:1' }) + await transport.connect({ + url: '', + cols: 80, + rows: 24, + callbacks: { onData: (data) => terminal.write(data) } + }) + + const handlers = installTerminalCapabilityReplyHandlers({ + terminal: terminal as never, + parser: terminal.parser, + // Matches pty-connection.ts: replies go out via sendInputImmediate. + sendInput: (data) => transport.sendInputImmediate(data), + isReplaying: () => false + }) + + return { + transport, + deliver: (data) => dispatchPtyData({ id: PTY_ID, data }), + renderedText: () => { + const lines: string[] = [] + for (let row = 0; row < terminal.rows; row += 1) { + lines.push(terminal.buffer.active.getLine(row)?.translateToString(true) ?? '') + } + return lines.join('\n').trim() + }, + dispose: () => { + handlers.dispose() + terminal.dispose() + } + } +} + +async function settleUntil(condition: () => boolean, timeoutMs = 500): Promise { + const deadline = Date.now() + timeoutMs + while (!condition() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1)) + } + expect(condition()).toBe(true) +} + +describe('#12112 opencode startup OSC 10/11 replies on the local path', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('plain terminal tab consumes the replies and renders nothing', async () => { + const tty = createStartupTty() + stubPtyApi(tty) + const pane = await createRendererPane() + // A Ctrl+T tab has no launch agent, so shouldReplyToStartupTerminalColorQueries + // (terminal-startup-color-query-replies.ts) is false, main arms no startup + // ingress, and the pane's own xterm is the sole responder. + expect(isTuiAgent(undefined)).toBe(false) + tty.onPtyOutput(pane.deliver) + + try { + tty.emitStartupBurst() + await settleUntil(() => tty.programInput().includes(OSC11_REPLY)) + + expect(pane.renderedText()).not.toMatch(LEAKED_COLOR_REPLY_TEXT) + expect(tty.programInput()).toContain(OSC10_REPLY) + expect(tty.programInput()).toContain(OSC11_REPLY) + } finally { + pane.dispose() + } + }) + + it('agent pane consumes the replies and renders nothing', async () => { + const tty = createStartupTty() + stubPtyApi(tty) + const pane = await createRendererPane() + + // opencode is a TUI agent, so main arms spawnOptions.startupIngress for this + // pane and only this pane (pty.ts:4024, terminal-startup-color-query-replies.ts). + expect(isTuiAgent('opencode')).toBe(true) + const ingress = new PtyStartupIngress({ + intent: { colors: ORCA_TERMINAL_THEME, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => tty.writeToPty(data), + onEmission: (emission) => pane.deliver(emission.data) + }) + tty.onPtyOutput((data) => ingress.accept(data)) + + try { + tty.emitStartupBurst() + await settleUntil(() => tty.programInput().includes(OSC11_REPLY)) + + expect(pane.renderedText()).not.toMatch(LEAKED_COLOR_REPLY_TEXT) + expect(tty.programInput()).toContain(OSC10_REPLY) + expect(tty.programInput()).toContain(OSC11_REPLY) + } finally { + ingress.drainAndClose() + pane.dispose() + } + }) + + it('renders no reply text when the tty echo is coalesced with program output', () => { + // The reported topology: the agent is launched by writing `opencode\n` into an + // interactive shell, so bash's echo of Orca's reply shares a read with the shell's + // own echo and the agent's first frame. It is never at the head of a chunk, and a + // read carrying no echo at all comes first. + vi.useFakeTimers() + const echoLayouts = [ + (replies: readonly string[]) => + `opencode\r\n\x1b[2Jloading${replies.map(readlineEchoOf).join('')}\r\n$ `, + // Both slots answered, but the agent draws between the two echoes. + (replies: readonly string[]) => + replies.map((reply) => `${readlineEchoOf(reply)}FRAME\r\n`).join('') + ] + + for (const [index, layout] of echoLayouts.entries()) { + const emitted: string[] = [] + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: ORCA_TERMINAL_THEME, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emitted.push(emission.data) + }) + ingress.accept(OPENCODE_STARTUP_QUERY_BURST) + vi.advanceTimersByTime(0) + expect(writes, `layout ${index}`).toEqual([OSC10_REPLY, OSC11_REPLY]) + + ingress.accept(layout(writes)) + ingress.drainAndClose() + + expect(emitted.join(''), `layout ${index}`).not.toMatch(LEAKED_COLOR_REPLY_TEXT) + } + }) + + it('suppresses its own cooked echo on POSIX and ConPTY', () => { + // No xterm, no transport: the asymmetry alone, with each backend's observed + // cooked echo handed straight back the way its line discipline would. + vi.useFakeTimers() + const forward = (ownerBackend: 'posix-pty' | 'windows-conpty') => { + const echoOf = + ownerBackend === 'windows-conpty' + ? (reply: string): string => reply.replaceAll('\x1b', '') + : readlineEchoOf + const emitted: string[] = [] + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: ORCA_TERMINAL_THEME, deadlineMs: 5_000 }, + ownerBackend, + write: (data) => { + writes.push(data) + ingress.accept(echoOf(data)) + }, + onEmission: (emission) => emitted.push(emission.data) + }) + ingress.accept(OPENCODE_STARTUP_QUERY_BURST) + // Why: the posix write is deferred, so draining first would make this arm + // assert on a stream where no reply was ever sent. + vi.advanceTimersByTime(0) + ingress.drainAndClose() + return { visible: emitted.join(''), writes } + } + + for (const ownerBackend of ['windows-conpty', 'posix-pty'] as const) { + const { visible, writes } = forward(ownerBackend) + expect(writes, ownerBackend).toEqual([OSC10_REPLY, OSC11_REPLY]) + expect(visible, ownerBackend).not.toMatch(LEAKED_COLOR_REPLY_TEXT) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index 7977196979e..626168ef265 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -33,13 +33,15 @@ beforeAll(() => { }) import { - buildFontFamily, buildPostReplayLiveAgentReattachReset, POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_MODE_RESET, replayPayloadEndsWithCursorHidden, RESET_KITTY_KEYBOARD_PROTOCOL, - RESET_TERMINAL_CURSOR_STYLE, + RESET_TERMINAL_CURSOR_STYLE +} from '../../../../shared/terminal-mode-reset-profiles' +import { + buildFontFamily, restoreScrollbackBuffers, serializePaneTree, serializeTerminalLayout, diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 6fb2df02703..2f8d0fea2f9 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -4,6 +4,7 @@ import type { TerminalPaneSplitDirection } from '../../../../shared/types' import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { POST_REPLAY_MODE_RESET } from '../../../../shared/terminal-mode-reset-profiles' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport' @@ -27,40 +28,6 @@ export const EMPTY_LAYOUT: TerminalLayoutSnapshot = { expandedLeafId: null } -// Why: SerializeAddon replays mode bits assuming reattach to a live TUI, but Orca restores against a fresh shell with none, so stale bits (e.g. focus reporting rings the bell on click) must be reset. -export const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' -export const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u' -// Every mouse mode the daemon can re-arm from a snapshot: protocols 9/1000/1002/1003 + SGR encodings 1006/1016. -export const RESET_MOUSE_REPORTING = - '\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l' - -export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l\x1b[?2004l` - -// Why: same-session live replay; keep cursor/focus cleanup but preserve Kitty flags the running TUI relies on. -export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` - -// Why: daemon reattach hits a live session, so skip the full reset; still clear cursor/focus/mouse/Kitty bits harmful to a plain shell after a bad TUI exit — safe for live TUIs since the post-reattach SIGWINCH repaints the cursor. -export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l` - -// Why: a live agent owns focus reporting; resetting ?1004h suppresses the focus-in it needs to re-anchor its cursor (IME). -export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h` - -// Why: DECTCEM applies in emission order, so the payload's last ?25l/?25h is the cursor state the TUI left. -export function replayPayloadEndsWithCursorHidden(payload: string): boolean { - const hideIndex = payload.lastIndexOf('\x1b[?25l') - return hideIndex !== -1 && hideIndex > payload.lastIndexOf('\x1b[?25h') -} - -// Why: some agents hide the real cursor and draw their own, so preserve the payload's final visibility (pty-connection re-shows it if the agent was actually a dead TUI). -export function buildPostReplayLiveAgentReattachReset(payload: string): string { - return replayPayloadEndsWithCursorHidden(payload) - ? `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}` - : POST_REPLAY_LIVE_AGENT_REATTACH_RESET -} - -// Why: a live agent owns cursor/focus here; forcing ?25h/?1004l breaks a parked agent that only arms ?1004h at startup. -export const POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET = RESET_TERMINAL_CURSOR_STYLE - // Cross-platform monospace chain: browsers skip fonts absent on the current OS, so listing all is safe. // Nerd Fonts come last to cover PUA glyphs (U+E000–U+F8FF) from OMP/Powerline that standard monospace fonts lack. const FALLBACK_FONTS = [ diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 701dd485890..8bb448ed11a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -10,7 +10,7 @@ import { POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE -} from './layout-serialization' +} from '../../../../shared/terminal-mode-reset-profiles' import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { DEFAULT_DA1_RESPONSE } from './terminal-capability-replies' import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator' @@ -9030,6 +9030,103 @@ describe('connectPanePty', () => { }) }) + it('ignores the stale agent signal on a cold restore and applies the fresh-shell reset', async () => { + // Why: pane status and title are persisted, so after a cold restore they still + // describe the process that died and make the pane look agent-owned. Preserving + // "its" modes arms mouse/focus/paste reporting against the replacement shell, + // which then prints the reports as junk at the prompt (#12101). + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: '\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + ([data]) => data as string + ) + expect(writes).toContain(POST_REPLAY_MODE_RESET) + expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + }) + }) + + it('applies the fresh-shell reset when a spawn is answered with a cold-restore reattach', async () => { + // Why: main can answer a *spawn* with an adopted session, so the reattach handler + // is reachable by a second door that skips the restored-session path entirely. The + // cold-restore signal has to survive that door too, or #12101 returns on it. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + let activePtyId = 'tab-pty' + transport.getPtyId.mockImplementation(() => activePtyId) + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + throw new Error('restored session is gone') + } + // Main answered the spawn by adopting a durable session instead. + activePtyId = 'adopted-pty' + return { + id: 'adopted-pty', + isReattach: true, + // Keep this snapshot free of ?25l: the live agent reset is built from the + // payload and only equals the constant negated below when the cursor is left + // visible. Ending it hidden would quietly retire that assertion. + snapshot: '\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(30) + + expect(transport.connect).toHaveBeenCalledTimes(2) + expect(transport.connect.mock.calls[0]?.[0]?.sessionId).toBe('tab-pty') + expect(transport.connect.mock.calls[1]?.[0]?.sessionId).toBeUndefined() + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + ([data]) => data as string + ) + const output = writes.join('') + const snapshotIndex = output.indexOf('\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ') + const resetIndex = output.indexOf(POST_REPLAY_MODE_RESET) + expect(snapshotIndex).toBeGreaterThanOrEqual(0) + expect(resetIndex).toBeGreaterThan(snapshotIndex) + expect(writes).toContain(POST_REPLAY_MODE_RESET) + expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + }) + }) + it('keeps ?25h in the live agent reattach reset when the snapshot leaves the cursor visible', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b5172cd89e3..fdace14f0d6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -123,7 +123,7 @@ import { POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE -} from './layout-serialization' +} from '../../../../shared/terminal-mode-reset-profiles' import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan' import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery' @@ -3012,7 +3012,7 @@ export function connectPanePty( // rings the bell. This is specific to terminals with cross-restart // persistence (as we have); our fix is to reset 1004 and friends after // scrollback replay so the mode state matches the fresh shell - // underneath. See POST_REPLAY_MODE_RESET in layout-serialization.ts. + // underneath. See POST_REPLAY_MODE_RESET in shared/terminal-mode-reset-profiles.ts. const onBell = (): void => { // Why: restored Claude Code sessions have been observed to emit a real // standalone BEL some time after daemon snapshot reattach, even when Orca @@ -5492,7 +5492,15 @@ export function connectPanePty( }) } - const reattachReplayResetSequence = (payload: string): string => { + const reattachReplayResetSequence = (payload: string, ownerProcessEnded = false): string => { + // Why a cold restore overrides the agent signal: liveness is read from the + // pane's status and title, both of which are persisted, so after a cold + // restore they describe the process that died. Preserving "its" modes arms + // mouse, focus and paste reporting against the fresh shell that replaces it, + // which then prints the reports as junk at the prompt (#12101). + if (ownerProcessEnded) { + return POST_REPLAY_MODE_RESET + } return shouldPreserveAgentReattachModes() ? buildPostReplayLiveAgentReattachReset(payload) : POST_REPLAY_REATTACH_RESET @@ -7936,8 +7944,10 @@ export function connectPanePty( // Why: re-arm the kitty keyboard mirror from the snapshot preamble so Option chords keep their encoding after a window reload. kittyKeyboardModes.scanReplay(connectResult.snapshot) writeReplayData(connectResult.snapshot) - // Snapshot reattach keeps a live session, so drop only renderer-owned state instead of the broader mode reset. - writeReplayData(reattachReplayResetSequence(connectResult.snapshot)) + // Snapshot reattach keeps a live session, so drop only renderer-owned state instead of the broader mode reset — unless this is a cold restore, whose owner is gone. + writeReplayData( + reattachReplayResetSequence(connectResult.snapshot, Boolean(connectResult.coldRestore)) + ) if (connectResult.pendingEscapeTailAnsi) { // Why last: re-arm the dangling mid-escape after the reset (whose ESC would abort it) so the live continuation completes it (#7329). writeReplayData(connectResult.pendingEscapeTailAnsi) @@ -7990,7 +8000,9 @@ export function connectPanePty( for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot)) { writeReplayData(replayChunk) } - writeReplayData(reattachReplayResetSequence(modelData)) + writeReplayData( + reattachReplayResetSequence(modelData, Boolean(connectResult?.coldRestore)) + ) if (modelSnapshot.pendingEscapeTailAnsi) { // Why last: re-arm the dangling mid-escape after the reset so the live continuation completes it (#7329). writeReplayData(modelSnapshot.pendingEscapeTailAnsi) @@ -8009,7 +8021,9 @@ export function connectPanePty( // Why: raw relay replay may contain the app's own kitty pushes; re-arm with set semantics so redelivery can't grow the stack. kittyKeyboardModes.scanReplay(connectResult.replay) writeReplayData(connectResult.replay) - writeReplayData(reattachReplayResetSequence(connectResult.replay)) + writeReplayData( + reattachReplayResetSequence(connectResult.replay, Boolean(connectResult.coldRestore)) + ) sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) if (connectResult.coldRestore) { if (!isRemoteRuntimePtyId(ptyId)) { diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index 9e927552049..3072b98dad2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -7,7 +7,7 @@ import { POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE -} from './layout-serialization' +} from '../../../../shared/terminal-mode-reset-profiles' const OLD_REATTACH_RESET_WITHOUT_CURSOR_STYLE = '\x1b[?25h\x1b[?1004l' diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 113fd52a527..23a7475f5dc 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -65,10 +65,10 @@ import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { buildFontFamily, normalizeTerminalLayoutSnapshot, - RESET_KITTY_KEYBOARD_PROTOCOL, replayTerminalLayout, restoreScrollbackBuffers } from './layout-serialization' +import { RESET_KITTY_KEYBOARD_PROTOCOL } from '../../../../shared/terminal-mode-reset-profiles' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { makePaneKey } from '../../../../shared/stable-pane-id' import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse' diff --git a/src/shared/pty-slave-line-discipline-echo.test.ts b/src/shared/pty-slave-line-discipline-echo.test.ts new file mode 100644 index 00000000000..a1889957114 --- /dev/null +++ b/src/shared/pty-slave-line-discipline-echo.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const execFileMock = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + +import { createPtySlaveEchoProbe, readPtySlavePath } from './pty-slave-line-discipline-echo' + +/** Replies to the next stty call with the given output, or an error when `output` is null. */ +function answerStty(output: string | null): void { + execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => { + cb(output === null ? new Error('stty: no such file') : null, output ?? '', '') + }) +} + +const COOKED = 'speed 38400 baud;\nlflags: icanon isig iexten echo echoe echok echoctl\n' +const RAW = 'speed 38400 baud;\nlflags: -icanon -isig -iexten -echo -echoe -echok -echoctl\n' + +beforeEach(() => { + execFileMock.mockReset() +}) + +describe('readPtySlavePath', () => { + it('reads node-pty ptsName and rejects every shape that is not a usable path', () => { + expect(readPtySlavePath({ ptsName: '/dev/ttys048' })).toBe('/dev/ttys048') + // A ConPTY terminal has no ptsName at all, and an empty one names no device. + expect(readPtySlavePath({})).toBeUndefined() + expect(readPtySlavePath({ ptsName: '' })).toBeUndefined() + expect(readPtySlavePath({ ptsName: 12 })).toBeUndefined() + expect(readPtySlavePath(undefined)).toBeUndefined() + expect(readPtySlavePath(null)).toBeUndefined() + }) +}) + +describe('createPtySlaveEchoProbe', () => { + it('has no probe to offer when there is no POSIX slave to read', () => { + expect(createPtySlaveEchoProbe('/dev/ttys048', 'win32')).toBeUndefined() + expect(createPtySlaveEchoProbe(undefined, 'darwin')).toBeUndefined() + }) + + it('reads the ECHO bit off the slave', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + answerStty(COOKED) + await expect(probe?.()).resolves.toBe('echoing') + answerStty(RAW) + await expect(probe?.()).resolves.toBe('quiet') + }) + + it('does not read `echoctl` or `echoe` as the ECHO bit', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + // Why: a substring match on "echo" reports echoing for a raw tty that merely keeps + // echoctl set, which is the exact tty the write must not be held back for. + answerStty('lflags: -icanon -echo echoe echok echoctl echoke\n') + await expect(probe?.()).resolves.toBe('quiet') + }) + + it('reports unknown rather than quiet when the slave cannot be read', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + answerStty(null) + await expect(probe?.()).resolves.toBe('unknown') + }) + + it('reports unknown when the output carries no echo flag at all', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + answerStty('speed 38400 baud;\n') + await expect(probe?.()).resolves.toBe('unknown') + }) + + it('stops spawning stty once it has failed, but keeps re-reading a live slave', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + answerStty(null) + await probe?.() + await probe?.() + await probe?.() + expect(execFileMock).toHaveBeenCalledTimes(1) + + // The bit itself is what changes, so a working probe is never cached. + const live = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + answerStty(COOKED) + await expect(live?.()).resolves.toBe('echoing') + answerStty(RAW) + await expect(live?.()).resolves.toBe('quiet') + expect(execFileMock).toHaveBeenCalledTimes(3) + }) + + it('keeps probing after a transient failure and only latches a permanent one', async () => { + const probe = createPtySlaveEchoProbe('/dev/ttys048', 'darwin') + // Why: a multi-pane restore forks these in a burst, so EAGAIN and the timeout kill + // are contention — condemning the pty to guessing for its whole life on one of + // those is the failure mode, not the protection. + for (const transient of [ + Object.assign(new Error('spawn EAGAIN'), { code: 'EAGAIN' }), + Object.assign(new Error('killed'), { killed: true }), + Object.assign(new Error('too many files'), { code: 'EMFILE' }) + ]) { + execFileMock.mockImplementationOnce((_c, _a, _o, cb) => cb(transient, '', '')) + await expect(probe?.()).resolves.toBe('unknown') + } + execFileMock.mockImplementationOnce((_c, _a, _o, cb) => cb(null, RAW, '')) + await expect(probe?.()).resolves.toBe('quiet') + expect(execFileMock).toHaveBeenCalledTimes(4) + + // A non-zero exit means the device is gone or was never a tty: permanent. + execFileMock.mockImplementationOnce((_c, _a, _o, cb) => + cb(Object.assign(new Error('not a tty'), { code: 1 }), '', '') + ) + await expect(probe?.()).resolves.toBe('unknown') + await expect(probe?.()).resolves.toBe('unknown') + expect(execFileMock).toHaveBeenCalledTimes(5) + }) + + it('passes the device with the flag its own platform understands', async () => { + answerStty(RAW) + await createPtySlaveEchoProbe('/dev/ttys048', 'darwin')?.() + expect(execFileMock.mock.calls[0]?.[1]).toEqual(['-a', '-f', '/dev/ttys048']) + answerStty(RAW) + await createPtySlaveEchoProbe('/dev/pts/3', 'linux')?.() + expect(execFileMock.mock.calls[1]?.[1]).toEqual(['-a', '-F', '/dev/pts/3']) + }) +}) diff --git a/src/shared/pty-slave-line-discipline-echo.ts b/src/shared/pty-slave-line-discipline-echo.ts new file mode 100644 index 00000000000..c54cd61e974 --- /dev/null +++ b/src/shared/pty-slave-line-discipline-echo.ts @@ -0,0 +1,102 @@ +import { execFile, type ExecFileException } from 'node:child_process' + +// Why this exists: a startup color reply is written to the PTY master, and a POSIX +// line discipline in ECHO copies it straight back out as visible junk (#12112). +// Whether that will happen is readable state on the slave, not something that has to +// be inferred from the bytes that come back — so Orca asks instead of guessing. + +/** `unknown` means "could not be determined", never "assume quiet". */ +export type PtySlaveLineDisciplineEcho = 'echoing' | 'quiet' | 'unknown' + +export type PtySlaveEchoProbe = () => Promise + +const STTY_TIMEOUT_MS = 2_000 +// `stty -a` prints the lflags as a space-separated list where a disabled flag is +// prefixed with `-`, so `echo` and `-echo` are the two tokens that matter. +const ECHO_FLAG = /(?:^|\s)(-?)echo(?:\s|$)/ + +function sttyArgs(ptsName: string, platform: NodeJS.Platform): readonly string[] { + // BSD/macOS take `-f`; Linux (GNU coreutils) takes `-F`. + return platform === 'darwin' || platform.includes('bsd') + ? ['-a', '-f', ptsName] + : ['-a', '-F', ptsName] +} + +function parseEchoFlag(sttyOutput: string): PtySlaveLineDisciplineEcho { + const match = ECHO_FLAG.exec(sttyOutput) + if (!match) { + return 'unknown' + } + return match[1] === '-' ? 'quiet' : 'echoing' +} + +type SttyProbeResult = { state: PtySlaveLineDisciplineEcho; permanent: boolean } + +/** + * A spawn that never ran (`stty` absent) or a device that answered non-zero (reaped, + * not a tty) will answer the same way forever. A kill by the timeout, or a fork that + * failed for want of a resource, is contention — the very thing a multi-pane restore + * produces — and must not condemn the pty to guessing for the rest of its life. + */ +function isPermanentSttyFailure(error: ExecFileException): boolean { + if (error.killed || error.signal) { + return false + } + return error.code !== 'EAGAIN' && error.code !== 'EMFILE' && error.code !== 'ENFILE' +} + +function runStty(ptsName: string, platform: NodeJS.Platform): Promise { + return new Promise((resolve) => { + execFile( + 'stty', + sttyArgs(ptsName, platform), + { timeout: STTY_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => { + resolve( + error + ? { state: 'unknown', permanent: isPermanentSttyFailure(error) } + : { state: parseEchoFlag(stdout), permanent: false } + ) + } + ) + }) +} + +/** + * node-pty's UnixTerminal carries the slave device path, but its public typings do not + * declare it and the Windows terminal has no such field — so read it defensively. + */ +export function readPtySlavePath(pty: unknown): string | undefined { + const candidate = (pty as { ptsName?: unknown } | null | undefined)?.ptsName + return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined +} + +/** + * Probe for whether the slave would echo a write to the master right now. + * + * Returns undefined when the platform has no line discipline to read: ConPTY and + * wsl.exe do not echo a master write at all, so a caller with no probe is correct to + * write immediately rather than degraded. A probe that exists but answers `unknown` + * is the degraded case, and callers must not read that as `quiet`. + */ +export function createPtySlaveEchoProbe( + ptsName: string | undefined, + platform: NodeJS.Platform = process.platform +): PtySlaveEchoProbe | undefined { + if (platform === 'win32' || !ptsName) { + return undefined + } + // Why latch: `stty` missing or the slave already reaped is a permanent condition for + // this pty, and the caller polls — without this a dead probe respawns a process per + // attempt. A successful probe is never cached, because the bit is what changes, and a + // transient failure is not latched at all (see isPermanentSttyFailure). + let unavailable = false + return async () => { + if (unavailable) { + return 'unknown' + } + const result = await runStty(ptsName, platform) + unavailable = result.permanent + return result.state + } +} diff --git a/src/shared/pty-startup-ingress-contract.ts b/src/shared/pty-startup-ingress-contract.ts index c8e865faa99..461c6d6c836 100644 --- a/src/shared/pty-startup-ingress-contract.ts +++ b/src/shared/pty-startup-ingress-contract.ts @@ -1,5 +1,6 @@ import type { PtyOwnerBackend } from './pty-owner-backend' import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' +import type { PtySlaveEchoProbe } from './pty-slave-line-discipline-echo' export type PtyIngressEmission = { data: string @@ -13,6 +14,12 @@ export type PtyStartupIngressOptions = { ownerBackend?: PtyOwnerBackend write: (data: string) => void onEmission: (emission: PtyIngressEmission) => void + /** + * Reports whether the slave would echo a reply written to the master. When present, + * the reply waits for `quiet` instead of relying on echo-shape recognition. Absent + * on backends with no line discipline to read (ConPTY, wsl.exe). + */ + echoProbe?: PtySlaveEchoProbe } export type PtyIngressSourceSpan = { @@ -27,6 +34,7 @@ export type PtyStartupIngressOperation = | { kind: 'snapshot' } | { kind: 'teardown' } | { kind: 'expire' } + | { kind: 'release-echo' } export function slicePtyIngressSourceSpan( span: PtyIngressSourceSpan, diff --git a/src/shared/pty-startup-ingress.test.ts b/src/shared/pty-startup-ingress.test.ts index a6ec33a371c..c45d546d59c 100644 --- a/src/shared/pty-startup-ingress.test.ts +++ b/src/shared/pty-startup-ingress.test.ts @@ -4,10 +4,28 @@ import { parsePtyStartupIngressIntent, type PtyIngressEmission } from './pty-startup-ingress' +import type { + PtySlaveEchoProbe, + PtySlaveLineDisciplineEcho +} from './pty-slave-line-discipline-echo' const COLORS = { foreground: '#2e3434', background: '#ffffff' } +const FOREGROUND_REPLY = '\x1b]10;rgb:2e2e/3434/3434\x1b\\' +const BACKGROUND_REPLY = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' +// The two echo shapes a cooked POSIX tty produces for a written reply: ECHOCTL +// caret forms, and readline eating `ESC ]` / ST while self-inserting the rest. +const POSIX_COOKED_ECHOES = [ + (reply: string): string => reply.replaceAll('\x1b', '^['), + (reply: string): string => reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', '') +] -function createHarness(options: { projection?: boolean; nested?: (data: string) => void } = {}) { +function createHarness( + options: { + projection?: boolean + nested?: (data: string) => void + echoProbe?: PtySlaveEchoProbe + } = {} +) { const emissions: PtyIngressEmission[] = [] let ingress!: PtyStartupIngress const writes: string[] = [] @@ -17,6 +35,7 @@ function createHarness(options: { projection?: boolean; nested?: (data: string) deadlineMs: 5_000 }, ...(options.projection ? { ownerBackend: 'windows-conpty' as const } : {}), + ...(options.echoProbe ? { echoProbe: options.echoProbe } : {}), write: (data) => { writes.push(data) options.nested?.(data) @@ -26,6 +45,19 @@ function createHarness(options: { projection?: boolean; nested?: (data: string) return { ingress, writes, emissions } } +/** Probe that answers from a script, repeating its last answer once exhausted. */ +function scriptedEchoProbe(...states: PtySlaveLineDisciplineEcho[]) { + let index = 0 + const probe: PtySlaveEchoProbe & { calls: number } = Object.assign( + async () => { + probe.calls += 1 + return states[Math.min(index++, states.length - 1)] ?? 'unknown' + }, + { calls: 0 } + ) + return probe +} + function visible(emissions: readonly PtyIngressEmission[]): string { return emissions.map((emission) => emission.data).join('') } @@ -42,12 +74,17 @@ describe('PtyStartupIngress', () => { expect(parsePtyStartupIngressIntent({ ...intent, deadlineMs: 30_001 })).toBeUndefined() }) - it('recognizes BEL/ST queries at every split and emits canonical replies', () => { + it('recognizes BEL/ST queries at every split and defers canonical replies', () => { + vi.useFakeTimers() const query = '\x1b]10;?\x07\x1b]11;?\x1b\\' for (let split = 0; split <= query.length; split += 1) { const { ingress, writes, emissions } = createHarness() ingress.accept(query.slice(0, split)) ingress.accept(query.slice(split)) + // Why: answering inside the query's own turn beats the querying program's + // tcsetattr, so a cooked tty echoes the reply as text instead (#12112). + expect(writes, `split ${split}`).toEqual([]) + vi.advanceTimersByTime(0) ingress.drainAndClose() expect(visible(emissions), `split ${split}`).toBe('') expect(writes, `split ${split}`).toEqual([ @@ -116,6 +153,7 @@ describe('PtyStartupIngress', () => { }) it('serializes a synchronous nested provider callback after the consumed query span', () => { + vi.useFakeTimers() const emissions: PtyIngressEmission[] = [] let ingress!: PtyStartupIngress ingress = new PtyStartupIngress({ @@ -124,6 +162,7 @@ describe('PtyStartupIngress', () => { onEmission: (emission) => emissions.push(emission) }) ingress.accept('before\x1b]10;?\x07after') + vi.advanceTimersByTime(0) ingress.drainAndClose() expect(emissions.map(({ data, transformed }) => ({ data, transformed }))).toEqual([ { data: 'before', transformed: false }, @@ -260,6 +299,515 @@ describe('PtyStartupIngress', () => { expect(visible(emissions)).toBe(input) }) + it('swallows a cooked POSIX echo of its own reply without re-sending it', () => { + // Why never re-send: POSIX ECHO copies the reply to the master but leaves it in + // the slave input queue, so the program still reads it; a second write would + // arrive on its stdin as unsolicited input once it is raw. + vi.useFakeTimers() + for (const echoOf of POSIX_COOKED_ECHOES) { + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + let ingress!: PtyStartupIngress + ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => { + writes.push(data) + ingress.accept(echoOf(data)) + }, + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + expect(visible(emissions)).toBe('') + + vi.advanceTimersByTime(5_000) + expect(writes).toEqual([FOREGROUND_REPLY]) + expect(visible(emissions)).toBe('') + ingress.drainAndClose() + } + }) + + it('swallows a cooked POSIX echo coalesced behind earlier program output', () => { + // Why this shape: an agent pane is launched by writing a command into an interactive + // shell, so the tty echo of Orca's reply never arrives at the head of a read (#12112). + vi.useFakeTimers() + for (const echoOf of POSIX_COOKED_ECHOES) { + const replies: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => replies.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07\x1b]11;?\x07') + vi.advanceTimersByTime(0) + expect(replies).toHaveLength(2) + + // A read with no echo in it must not retire the projections either. + ingress.accept('booting...\r\n') + ingress.accept(`\x1b[2JFRAME${replies.map((reply) => echoOf(reply)).join('')}`) + ingress.drainAndClose() + + expect(visible(emissions)).toBe('booting...\r\n\x1b[2JFRAME') + } + }) + + it('answers both slots when the deferred write lands between two reads of the burst', () => { + // Why between: a pty read boundary is a macrotask, so the deferred reply is written + // while the rest of the burst is still unread. A `\x07` head-of-echo guess taken then + // steals the OSC 11 terminator, leaving the slot unanswered and its bytes emitted + // after the BEL — which parks xterm in an OSC that never terminates. + vi.useFakeTimers() + const burst = '\x1b]10;?\x07\x1b]11;?\x07' + for (let split = 0; split <= burst.length; split += 1) { + const { ingress, writes, emissions } = createHarness() + ingress.accept(burst.slice(0, split)) + vi.advanceTimersByTime(0) + ingress.accept(burst.slice(split)) + vi.advanceTimersByTime(0) + ingress.drainAndClose() + + expect(writes, `split ${split}`).toEqual([FOREGROUND_REPLY, BACKGROUND_REPLY]) + expect(visible(emissions), `split ${split}`).toBe('') + } + }) + + it('keeps raw ranges disjoint when an echo lands on a retained torn query', () => { + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness() + ingress.accept('\x1b]10;?\x07\x1b]11;?') + vi.advanceTimersByTime(0) + ingress.accept(`${writes[0]?.replaceAll('\x1b', '^[')}tail`) + const accepted = ingress.drainAndClose() + + expect(visible(emissions)).toBe('\x1b]11;?tail') + // Why exact ranges: a candidate carried across the suppressed echo re-emits its own + // bytes on a span whose end no longer matches its data, so ranges start to overlap. + expect(emissions.map((item) => [item.rawStartSeq, item.rawEndSeq])).toEqual([ + [0, 7], + [7, 13], + [13, 40], + [40, accepted] + ]) + }) + + it('releases a partial echo hold long before the startup deadline', () => { + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness() + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + // Why a range and not the exact hold: what matters is that the guess outlasts + // relay jitter yet still resolves without the deadline's help. Pinning the exact + // value would fail on any honest retune while teaching the retuner nothing. + const RELAY_JITTER_MS = 400 + const WELL_BELOW_DEADLINE_MS = 1_500 + + // A lone BEL is the head of the readline echo projection, so it is held. + ingress.accept('\x07') + expect(visible(emissions)).toBe('') + vi.advanceTimersByTime(RELAY_JITTER_MS) + expect(visible(emissions)).toBe('') + vi.advanceTimersByTime(WELL_BELOW_DEADLINE_MS - RELAY_JITTER_MS) + + expect(visible(emissions)).toBe('\x07') + ingress.drainAndClose() + }) + + it('still swallows the echo of a reply the startup deadline raced', () => { + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + vi.advanceTimersByTime(4_999) + ingress.accept('\x1b]10;?\x07') + // The deferred write flushes at 4_999, then the deadline expires at 5_000. + vi.advanceTimersByTime(2) + expect(writes).toEqual([FOREGROUND_REPLY]) + + ingress.accept(FOREGROUND_REPLY.replaceAll('\x1b', '^[')) + ingress.drainAndClose() + expect(visible(emissions)).toBe('') + }) + + it('writes a reply the startup deadline raced instead of dropping it', () => { + // Why: the query span was already consumed, so nobody downstream can answer it. + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + vi.advanceTimersByTime(4_999) + ingress.accept('\x1b]10;?\x07') + expect(writes).toEqual([]) + vi.advanceTimersByTime(1) + + expect(visible(emissions)).toBe('') + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.drainAndClose() + }) + + it('keeps the synchronous write for ConPTY-hosted wsl.exe panes', () => { + // Why: a Windows-hosted pty must be answered before conhost's own responder. + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'windows-wsl', + write: (data) => writes.push(data), + onEmission: () => {} + }) + + ingress.accept('\x1b]10;?\x07') + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.drainAndClose() + }) + + it('swallows a coalesced POSIX echo torn at every byte boundary', () => { + // Why a prefix matters: recognition used to hold a split echo only when it began + // at offset 0, so a single byte of program output ahead of it made every torn + // boundary leak the reply verbatim — the exact #12112 symptom the fix targets. + vi.useFakeTimers() + for (const echoOf of POSIX_COOKED_ECHOES) { + const echo = echoOf(FOREGROUND_REPLY) + for (let split = 1; split < echo.length; split += 1) { + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + ingress.accept(`FRAME${echo.slice(0, split)}`) + ingress.accept(echo.slice(split)) + ingress.drainAndClose() + + expect(visible(emissions)).toBe('FRAME') + } + } + }) + + it('still recognizes an echo that arrives behind an enormous splash frame', () => { + // Why: the search budget must not be spent by one large frame, retiring the + // projection while the echo is still in flight behind it. + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + // One enormous splash frame must not retire the projection. + ingress.accept('x'.repeat(64_000)) + ingress.accept(FOREGROUND_REPLY.replaceAll('\x1b', '^[')) + ingress.drainAndClose() + + expect(visible(emissions)).toBe('x'.repeat(64_000)) + }) + + it('stops shadowing the stream once the projection outlives its search budget', () => { + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + const printed = 'tick\r\n'.repeat(50_000) + ingress.accept(printed) + + // The echo never came, so a later exact collision is ordinary output again. + const collision = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + ingress.accept(collision) + ingress.drainAndClose() + expect(visible(emissions)).toBe(`${printed}${collision}`) + }) + + it('bounds echo suppression to a few hundred bytes past the startup deadline', () => { + // Why: reset() keeps a raced reply recognizable, but an unbounded projection would + // keep deleting matching spans out of ordinary output for the rest of the session. + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + vi.advanceTimersByTime(4_999) + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(2) + expect(writes).toEqual([FOREGROUND_REPLY]) + + const printed = 'a\r\n'.repeat(200) + ingress.accept(printed) + const collision = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + ingress.accept(collision) + ingress.drainAndClose() + + expect(visible(emissions)).toBe(`${printed}${collision}`) + }) + + it('drops its answered claim when a deferred write fails so a retry falls through', () => { + // Why: the deferred write already reported success, so the first query was consumed + // on its behalf. Without the rollback the slot stays claimed forever and no + // downstream color authority ever sees the query either. + vi.useFakeTimers() + let failWrites = true + const emissions: PtyIngressEmission[] = [] + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => { + if (failWrites) { + throw new Error('EIO') + } + writes.push(data) + }, + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([]) + + failWrites = false + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + ingress.drainAndClose() + expect(writes).toEqual([FOREGROUND_REPLY]) + }) + + it('ages a projection out even when every read ends mid-candidate', () => { + // Why: the read budget is charged once per read at the entry point, so a stream + // whose every read ends on a candidate byte still retires a projection that never + // lands. Charging only on reads that fall through left it alive forever. + vi.useFakeTimers() + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + // A trailing `^` is a strict prefix of the caret projection, so every one of these + // reads returns holding a candidate. + let printed = '' + for (let read = 0; read < 8; read += 1) { + const chunk = `${'line of output\r\n'.repeat(4_000)}^` + printed += chunk + ingress.accept(chunk) + } + + const collision = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + ingress.accept(collision) + ingress.drainAndClose() + expect(visible(emissions)).toBe(`${printed}${collision}`) + }) + + it('swallows an echo no matter how finely the tty chunks it', () => { + // Why: an SSH relay or a slow drain delivers the echo a few bytes at a time. A + // per-read budget was spent inside the echo itself, so the leak came back for any + // chunking finer than the budget. + vi.useFakeTimers() + const echo = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + for (const chunkSize of [1, 2, 3, 5, 13]) { + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'posix-pty', + write: (data) => writes.push(data), + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + for (let at = 0; at < echo.length; at += chunkSize) { + ingress.accept(echo.slice(at, at + chunkSize)) + } + ingress.drainAndClose() + + expect({ chunkSize, visible: visible(emissions) }).toEqual({ chunkSize, visible: '' }) + } + }) + + it('lets every downstream barrier cut a partial echo hold short', () => { + // Why pinned: the hold window is only affordable because it is not what bounds + // the wait — these are. If one stopped releasing, the window would become a + // real stall rather than a bet on the next read. + vi.useFakeTimers() + const cutShort: Record void> = { + snapshotBarrier: (ingress) => ingress.snapshotBarrier(), + drainAndClose: (ingress) => ingress.drainAndClose(), + startupDeadline: () => vi.advanceTimersByTime(5_000) + } + for (const [name, cut] of Object.entries(cutShort)) { + const { ingress, writes, emissions } = createHarness() + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + // A lone BEL heads the readline projection, so it is held rather than shown. + ingress.accept('\x07') + expect({ name, held: visible(emissions) }).toEqual({ name, held: '' }) + cut(ingress) + + expect({ name, released: visible(emissions) }).toEqual({ name, released: '\x07' }) + } + }) + + it('keeps swallowing an echo split across the query-authority handoff', () => { + // Why the asymmetry with snapshotBarrier is deliberate: closing query authority + // hands off who may answer, but the reply is already on the wire and its echo is + // still Orca's to swallow. Cutting the hold here would show its first half. + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness() + const echo = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.accept(echo.slice(0, 10)) + ingress.closeQueryAuthority() + ingress.accept(echo.slice(10)) + ingress.drainAndClose() + + expect(visible(emissions)).toBe('') + }) + + it('swallows an echo whose halves straddle a relay-sized stall', () => { + // Why: an expired hold releases raw, so a hold shorter than real inter-chunk + // jitter reinstates the leak on exactly the links Orca has to work over. + vi.useFakeTimers() + const echo = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + for (const gapMs of [50, 200, 400]) { + const { ingress, writes, emissions } = createHarness() + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + + ingress.accept(echo.slice(0, 10)) + vi.advanceTimersByTime(gapMs) + ingress.accept(echo.slice(10)) + ingress.drainAndClose() + + expect({ gapMs, visible: visible(emissions) }).toEqual({ gapMs, visible: '' }) + } + }) + + it('swallows an echo that arrives behind a query torn on an earlier read', () => { + // Why: the tty can tear the program's second query and start echoing the first + // reply in the same read. Refusing to hold while a query is pending leaked the + // whole echo, because the prefix that would have completed that query was never + // emitted first. + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness() + const echo = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.accept('\x1b]11;') + ingress.accept(`?\x07${echo.slice(0, 8)}`) + ingress.accept(echo.slice(8)) + vi.advanceTimersByTime(0) + ingress.drainAndClose() + + expect(visible(emissions)).toBe('') + // The torn query is still answered: it is the prefix that completes it. + expect(writes).toEqual([FOREGROUND_REPLY, BACKGROUND_REPLY]) + }) + + it('drops a torn query the next read disproves instead of the echo behind it', () => { + // Why: with the echo starting at offset 0 there is no prefix to complete the torn + // candidate, so preferring it fed the echo to the raw path and printed both. The + // candidate is not a color query at all once the echo's first byte lands. + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness() + const echo = FOREGROUND_REPLY.replaceAll('\x1b', '^[') + + ingress.accept('\x1b]10;?\x07') + vi.advanceTimersByTime(0) + ingress.accept('\x1b]11;') + ingress.accept(echo.slice(0, 8)) + ingress.accept(echo.slice(8)) + vi.advanceTimersByTime(0) + ingress.drainAndClose() + + // Only the program's own bytes survive; the echo is gone rather than trailing them. + expect(visible(emissions)).toBe('\x1b]11;') + expect(writes).toEqual([FOREGROUND_REPLY]) + }) + + it('keeps a landed reply claimed when the sibling query write fails', () => { + // Why: ConPTY writes inside the query's own turn, so one span can land slot 10 and + // lose slot 11. Forgetting every claim would answer 10 a second time, and a + // duplicate reply corrupts a parser already mid-read. + const writes: string[] = [] + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + ownerBackend: 'windows-conpty', + write: (data) => { + if (data === BACKGROUND_REPLY) { + throw new Error('EIO') + } + writes.push(data) + }, + onEmission: (emission) => emissions.push(emission) + }) + + ingress.accept('\x1b]10;?\x07\x1b]11;?\x07') + ingress.accept('\x1b]10;?\x07\x1b]11;?\x07') + ingress.drainAndClose() + expect(writes).toEqual([FOREGROUND_REPLY]) + }) + it('ignores callbacks after teardown without recreating the raw sequence domain', () => { const { ingress, emissions } = createHarness({ projection: true }) ingress.accept('\x1b]10;?\x07') @@ -269,4 +817,140 @@ describe('PtyStartupIngress', () => { expect(ingress.acceptedRawSequence).toBe(closedAt) expect(visible(emissions)).toBe(']10;rgb:2e2e/') }) + + it('withholds the reply while the slave would echo it, then writes once it is quiet', async () => { + vi.useFakeTimers() + const echoProbe = scriptedEchoProbe('echoing', 'echoing', 'quiet') + const { ingress, writes } = createHarness({ echoProbe }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(0) + // Nothing may go out while the line discipline is still cooked: that write is the + // one that comes straight back as visible junk (#12112). + expect(writes).toEqual([]) + await vi.advanceTimersByTimeAsync(20) + expect(writes).toEqual([]) + await vi.advanceTimersByTimeAsync(20) + expect(writes).toEqual([FOREGROUND_REPLY]) + expect(echoProbe.calls).toBe(3) + ingress.drainAndClose() + }) + + it('retires only the kernel caret projection once the probe proves ECHO is clear', async () => { + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness({ + echoProbe: scriptedEchoProbe('quiet') + }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + // A cleared ECHO bit proves the kernel cannot produce the caret form, so output + // that merely resembles it is ordinary program output and must survive. + const caret = POSIX_COOKED_ECHOES[0]?.(FOREGROUND_REPLY) ?? '' + ingress.accept(caret) + ingress.drainAndClose() + expect(visible(emissions)).toBe(caret) + }) + + it('still suppresses the readline echo on a slave the probe called quiet', async () => { + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness({ + echoProbe: scriptedEchoProbe('quiet') + }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + // Why: readline echoes a master write in software with the tty already raw and + // ECHO off, so `quiet` is no evidence at all about this shape. Verified on a live + // pty: at a bash prompt the probe reports quiet and readline still emits it. + ingress.accept(POSIX_COOKED_ECHOES[1]?.(FOREGROUND_REPLY) ?? '') + ingress.drainAndClose() + expect(visible(emissions)).toBe('') + }) + + it('falls back to recognizing echo shapes when the probe cannot answer', async () => { + vi.useFakeTimers() + const { ingress, writes, emissions } = createHarness({ + echoProbe: scriptedEchoProbe('unknown') + }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([FOREGROUND_REPLY]) + // `unknown` is not evidence of quiet, so the guess stays armed and swallows the echo. + ingress.accept(POSIX_COOKED_ECHOES[0]?.(FOREGROUND_REPLY) ?? '') + ingress.drainAndClose() + expect(visible(emissions)).toBe('') + }) + + it('falls back immediately when the echo probe rejects', async () => { + vi.useFakeTimers() + const echoProbe: PtySlaveEchoProbe = async () => { + throw new Error('probe failed') + } + const { ingress, writes, emissions } = createHarness({ echoProbe }) + ingress.accept('\x1b]10;?\x07') + + await vi.advanceTimersByTimeAsync(0) + + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.accept(POSIX_COOKED_ECHOES[0]?.(FOREGROUND_REPLY) ?? '') + ingress.drainAndClose() + expect(visible(emissions)).toBe('') + }) + + it('stops polling a tty that never leaves cooked mode and answers it anyway', async () => { + vi.useFakeTimers() + const echoProbe = scriptedEchoProbe('echoing') + const { ingress, writes, emissions } = createHarness({ echoProbe }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(1_000) + // Waiting past this point only delays a reply that will echo whenever it is sent, + // so the reply goes out with the shape guess armed rather than being dropped. + expect(writes).toEqual([FOREGROUND_REPLY]) + // Bounded in wall-clock, not in probes: under fork contention each probe takes + // longer and the budget buys fewer of them, instead of the wait growing. + expect(echoProbe.calls).toBeLessThanOrEqual(11) + ingress.accept(POSIX_COOKED_ECHOES[0]?.(FOREGROUND_REPLY) ?? '') + ingress.drainAndClose() + expect(visible(emissions)).toBe('') + }) + + it('gives a later query its own probe budget, not the first query remainder', async () => { + vi.useFakeTimers() + const echoProbe = scriptedEchoProbe('echoing') + const { ingress, writes } = createHarness({ echoProbe }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(1_000) + expect(writes).toEqual([FOREGROUND_REPLY]) + const spentOnFirst = echoProbe.calls + // Why: OSC 10 and OSC 11 routinely arrive more than a budget apart over SSH. A + // counter carried across them would send the second reply out entirely unprobed. + ingress.accept('\x1b]11;?\x07') + await vi.advanceTimersByTimeAsync(1_000) + expect(writes).toEqual([FOREGROUND_REPLY, BACKGROUND_REPLY]) + expect(echoProbe.calls).toBeGreaterThan(spentOnFirst) + ingress.drainAndClose() + }) + + it('answers a still-pending reply when the startup deadline expires mid-poll', async () => { + vi.useFakeTimers() + const { ingress, writes } = createHarness({ echoProbe: scriptedEchoProbe('echoing') }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(60) + expect(writes).toEqual([]) + // The deadline is the outer bound: a reply held by a cooked tty still gets sent + // rather than dropped, because the querying program is blocked on it. + await vi.advanceTimersByTimeAsync(5_000) + expect(writes).toEqual([FOREGROUND_REPLY]) + ingress.drainAndClose() + }) + + it('drops a held reply on teardown instead of writing to a dead pty', async () => { + vi.useFakeTimers() + const { ingress, writes } = createHarness({ echoProbe: scriptedEchoProbe('echoing') }) + ingress.accept('\x1b]10;?\x07') + await vi.advanceTimersByTimeAsync(20) + ingress.drainAndClose() + await vi.advanceTimersByTimeAsync(1_000) + expect(writes).toEqual([]) + }) }) diff --git a/src/shared/pty-startup-ingress.ts b/src/shared/pty-startup-ingress.ts index 0675eb92a4b..edddd73dd43 100644 --- a/src/shared/pty-startup-ingress.ts +++ b/src/shared/pty-startup-ingress.ts @@ -5,6 +5,7 @@ import { } from './terminal-osc-color-reply' import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' import type { PtyOwnerBackend } from './pty-owner-backend' +import { PtyStartupReplyDelivery } from './pty-startup-reply-delivery' import { combinePtyIngressSourceSpans, slicePtyIngressSourceSpan, @@ -22,11 +23,13 @@ export type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' export type { PtyIngressEmission, PtyStartupIngressOptions } from './pty-startup-ingress-contract' const MAX_QUERY_CANDIDATE_CHARS = 64 - -function projectedWindowsConptyReply(reply: string): string { - // Why: the native provider harness observes ConPTY's cooked echo with ESC removed. - return reply.replaceAll('\x1b', '') -} +// Why this long: a torn echo whose halves straddle this window is released raw, so +// anything under relay jitter reinstates the leak (#12112). Almost nothing is risked +// by waiting, because the timer is rarely what ends a hold — the next read is, and +// the startup deadline and snapshot barrier both cap the wait independently. The +// exposure is at most one projection's worth of echo-shaped bytes on an already idle +// pane, which is why the guess is allowed to be slow rather than tight. +const ECHO_CONTINUATION_HOLD_MS = 500 /** * Serialized source-side startup classifier. Its raw sequence begins after @@ -35,23 +38,23 @@ function projectedWindowsConptyReply(reply: string): string { export class PtyStartupIngress { private readonly intent: PtyStartupIngressIntent | undefined private readonly ownerBackend: PtyOwnerBackend - private readonly writeProvider: (data: string) => void + private readonly delivery: PtyStartupReplyDelivery private readonly onEmission: (emission: PtyIngressEmission) => void private readonly operations: PtyStartupIngressOperation[] = [] private readonly answeredSlots = new Set() - private readonly expectedEchoes: string[] = [] private processing = false private closed = false private queryOpen: boolean private rawHighWater = 0 private queryPending: PtyIngressSourceSpan | null = null private echoPending: PtyIngressSourceSpan | null = null + private echoHoldTimer: ReturnType | null = null private deadlineTimer: ReturnType | null = null constructor(options: PtyStartupIngressOptions) { this.intent = options.intent this.ownerBackend = options.ownerBackend ?? 'posix-pty' - this.writeProvider = options.write + this.delivery = new PtyStartupReplyDelivery(this.ownerBackend, options.write, options.echoProbe) this.onEmission = options.onEmission this.queryOpen = options.intent !== undefined if (options.intent) { @@ -121,61 +124,110 @@ export class PtyStartupIngress { case 'close-query': if (this.ownerBackend !== 'windows-conpty') { this.queryOpen = false + // Why the echo hold deliberately survives this, unlike `snapshot`: the + // handoff ends query *authority*, but a reply already on the wire is still + // Orca's to swallow. Releasing here would show the first half of an echo + // split across the boundary and orphan the second. this.releaseQueryPending() } // Why: ConPTY cannot safely transfer color-query authority to a downstream view. return case 'expire': this.queryOpen = false - this.releaseEchoPending() - if (this.ownerBackend !== 'windows-conpty') { - this.releaseQueryPending() - } - this.expectedEchoes.length = 0 + this.releasePendingInSourceOrder(false) + this.delivery.reset() this.clearDeadline() return case 'snapshot': - this.releaseSnapshotPending() + case 'release-echo': + this.releasePendingInSourceOrder(false) return case 'teardown': this.queryOpen = false - this.releaseAllPending() - this.expectedEchoes.length = 0 + this.releasePendingInSourceOrder(true) + this.delivery.close() this.clearDeadline() this.closed = true } } + /** + * One PTY read. The charge is in `finally` because every path below can return + * early: charging after the match gives a real echo the whole read it arrives in, + * and charging unconditionally means a projection that never lands still ages out + * on the reads that end mid-candidate rather than shadowing the rest of the session. + * It charges the read, never the held-bytes-plus-read span, so a tail that waits + * across several reads is not billed again on each one. + */ private processEchoSpan(span: PtyIngressSourceSpan): void { - let input = combinePtyIngressSourceSpans(this.echoPending, span) - this.echoPending = null + try { + this.classifyRead(span) + } finally { + this.delivery.chargeEchoSearch(span.data.length) + } + } - while (this.expectedEchoes.length > 0) { - const expected = this.expectedEchoes[0] - const compared = Math.min(input.data.length, expected.length) - let matching = 0 - while (matching < compared && input.data[matching] === expected[matching]) { - matching += 1 - } - if (matching < compared) { - this.expectedEchoes.shift() - this.processQuerySpan(input) - return - } - if (input.data.length < expected.length) { - this.echoPending = input - return - } + private classifyRead(span: PtyIngressSourceSpan): void { + let input = combinePtyIngressSourceSpans(this.takeEchoPending(), span) - this.expectedEchoes.shift() - this.emit(slicePtyIngressSourceSpan(input, 0, expected.length), true, '') - input = slicePtyIngressSourceSpan(input, expected.length) - if (input.data.length === 0) { - return + while (this.delivery.hasExpectedEcho && input.data.length > 0) { + const match = this.delivery.matchEcho(input.data) + if (match.kind !== 'complete') { + // Why hold from the match rather than only at offset 0: the tty coalesces its + // echo with whatever the shell printed around it, so a split echo almost + // always arrives behind other bytes. Those bytes are emitted now and only the + // candidate tail waits, so recognition survives a split at any boundary + // without stalling real output. + if (match.kind === 'partial') { + const tail = slicePtyIngressSourceSpan(input, match.offset) + if (match.offset > 0) { + this.processQuerySpan(slicePtyIngressSourceSpan(input, 0, match.offset)) + } + // A still-torn query outranks the echo only while it can still become one: + // the tail may open with the BEL that terminates it, since the readline + // projection starts with one. Re-parsing it against the tail is what tells + // the two apart — a candidate the tail *disproves* is ordinary output that + // would otherwise absorb the echo behind it and dump both raw (#12112). + // + // `partial` counts as viable, not just `match`: the terminator can arrive a + // read later, and demoting it would emit a bare ESC and leave a real query + // unanswered until the program's own timeout. On ConPTY that costs an echo, + // because the ESC-stripped projection shares the `]10;` prefix with a real + // query and so keeps re-parsing as `partial` — a hang is the worse of the two. + if (this.queryPending) { + const resolved = combinePtyIngressSourceSpans(this.queryPending, tail) + if (parseTerminalOscColorQuery(resolved.data, 0).kind !== 'none') { + this.processQuerySpan(tail) + return + } + // Unconditional, unlike `releasePendingInSourceOrder`, which withholds a + // ConPTY candidate: that one releases candidates still *undetermined*, + // and on ConPTY an undetermined candidate may be a query it is meant to + // suppress. Here the candidate and the tail together parse as `none`, so + // whatever the candidate is, the bytes behind it are not its body — which + // is what makes it safe to stop holding the echo hostage to it. + this.releaseQueryPending() + } + this.echoPending = tail + this.armEchoHold() + return + } + break } + if (match.offset > 0) { + this.processQuerySpan(slicePtyIngressSourceSpan(input, 0, match.offset)) + } + // Why release first: a retained torn candidate cannot straddle the suppressed + // range without desynchronizing its raw sequence arithmetic. + this.releaseQueryPending() + const echoEnd = match.offset + match.length + this.emit(slicePtyIngressSourceSpan(input, match.offset, echoEnd), true, '') + input = slicePtyIngressSourceSpan(input, echoEnd) } - this.processQuerySpan(input) + if (input.data.length > 0) { + this.processQuerySpan(input) + } } private processQuerySpan(span: PtyIngressSourceSpan): void { @@ -244,22 +296,15 @@ export class PtyStartupIngress { return wroteAny } this.answeredSlots.add(slot) - const projected = - this.ownerBackend === 'windows-conpty' ? projectedWindowsConptyReply(reply) : null - if (projected) { - // Why: register before write because node-pty can synchronously re-enter onData. - this.expectedEchoes.push(projected) - } - try { - this.writeProvider(reply) - wroteAny = true - } catch { + // Why per slot: the replies to one query are written independently, so a + // deferred write that fails after reporting success invalidates only its own + // claim. Dropping every claim would let a slot that did land be answered a + // second time, and a duplicate reply corrupts a parser already mid-read. + if (!this.delivery.answer(reply, () => this.answeredSlots.delete(slot))) { this.answeredSlots.delete(slot) - if (projected) { - this.expectedEchoes.pop() - } return wroteAny } + wroteAny = true } if (this.answeredSlots.has(10) && this.answeredSlots.has(11)) { @@ -277,28 +322,41 @@ export class PtyStartupIngress { this.emit(pending, false) } - private releaseAllPending(): void { - this.releaseEchoPending() - this.releaseQueryPending() - } - - private releaseEchoPending(): void { - if (!this.echoPending) { - return - } - const pending = this.echoPending - this.echoPending = null - this.emit(pending, false) - } - - private releaseSnapshotPending(): void { - if (this.echoPending) { - this.expectedEchoes.shift() - this.releaseEchoPending() - } - if (this.ownerBackend !== 'windows-conpty') { + /** + * Why this order: were both ever live, queryPending would hold the earlier source + * bytes. `classifyRead` only ever arms one — it either keeps a viable query and + * returns, or releases a disproven one before holding the echo — so this is defense + * against a future second arming site, not a live inversion. + */ + private releasePendingInSourceOrder(includeConptyQuery: boolean): void { + if (includeConptyQuery || this.ownerBackend !== 'windows-conpty') { this.releaseQueryPending() } + const pending = this.takeEchoPending() + if (pending) { + this.emit(pending, false) + } + } + + private takeEchoPending(): PtyIngressSourceSpan | null { + const pending = this.echoPending + this.echoPending = null + if (this.echoHoldTimer) { + clearTimeout(this.echoHoldTimer) + this.echoHoldTimer = null + } + return pending + } + + private armEchoHold(): void { + if (this.echoHoldTimer) { + return + } + this.echoHoldTimer = setTimeout( + () => this.enqueue({ kind: 'release-echo' }), + ECHO_CONTINUATION_HOLD_MS + ) + this.echoHoldTimer.unref?.() } private emit(span: PtyIngressSourceSpan, transformed: boolean, data = span.data): void { diff --git a/src/shared/pty-startup-reply-delivery.ts b/src/shared/pty-startup-reply-delivery.ts new file mode 100644 index 00000000000..876fc26a000 --- /dev/null +++ b/src/shared/pty-startup-reply-delivery.ts @@ -0,0 +1,349 @@ +import type { PtyOwnerBackend } from './pty-owner-backend' +import type { PtySlaveEchoProbe } from './pty-slave-line-discipline-echo' + +// Why this module exists: a startup color reply is written to the PTY master, so +// whatever line discipline sits between Orca and the querying program can echo it +// straight back out as ordinary output (#12112). ConPTY echoes it with the ESC +// bytes stripped; a POSIX tty echoes it while the querying program is still cooked. +// A program that queries before clearing ECHO loses that race if Orca answers +// inside the query's own turn, so on POSIX the write waits until the slave's ECHO +// bit is observably clear, and recognized echo shapes cover what remains. +// +// Deliberately NO re-send on a matched echo: ECHO copies bytes to the master +// without consuming them from the slave's input queue, so a program that arms raw +// mode with TCSANOW/TCSADRAIN (libuv's setRawMode, hence Node-based agents) still +// receives the reply, and re-writing would duplicate it in stdin. A TCSAFLUSH +// switcher does discard it; that case is left to the query timeout, because a +// duplicate reply corrupts a parser that is already mid-read. +// +// Why not PostReadyFlushGate's settle-and-fallback shape, which solves this same +// "don't write while ECHO is on" race for shell startup input: that gate defers +// bytes nothing is waiting on, so it can wait for the stream to go observably +// quiet. A color reply is different — the querying program is blocked on it and +// times out — so the wait here is bounded by a budget and always ends in a write. +// +// There are TWO echo sources and they are independent, which is the thing to hold onto +// when reading the rest of this file: +// +// 1. The kernel line discipline, when ECHO is set. Readable state — the probe asks +// the slave directly, and waiting for it to clear removes this echo outright. +// 2. The foreground line editor, in software. readline echoes a master write as if +// it were typed *while the tty is raw with ECHO off*, so the probe's verdict says +// nothing about it. Verified on a live pty: at a bash prompt the probe reports +// `quiet` and readline still emits `BEL 10;rgb:2e2e/3434/3434`. +// +// So `quiet` is proof about (1) only. It gates the withholding and retires the caret +// projection, and must never be read as "no suppression needed" — that reintroduces +// #12112 at a shell prompt, which is the foreground for most of an agent pane's +// startup window. The projections below stay armed for (2) on every path. + +export type PtyStartupReplyEchoMatch = + | { kind: 'complete'; offset: number; length: number } + | { kind: 'partial'; offset: number } + | { kind: 'none' } + +// Why bytes and not reads: the echo is a fixed ~30 bytes, but nothing bounds how the +// tty chunks them — an SSH relay or a slow drain delivers a few bytes at a time, and a +// per-read budget is then spent inside the echo itself. What actually bounds a live +// projection is the startup deadline; this is only a backstop against a pathological +// pre-deadline stream, so it is set well above any splash an echo could arrive behind. +const ECHO_SEARCH_BUDGET_BYTES = 256 * 1024 +// Why far tighter past the deadline: a reply still on the wire at expiry deserves the +// read or two its echo takes, but nothing beyond it — see reset(). +const ECHO_POST_DEADLINE_BUDGET_BYTES = 512 +// Why this tight: the querying program is blocked on the reply, so every interval is +// latency it pays. A raw-mode switch lands within a turn or two of the query, and the +// probe is a subprocess — this is the smallest interval that does not spin on it. +const ECHO_POLL_INTERVAL_MS = 20 +// Why a budget at all: the startup deadline runs to 30s, which at this interval is a +// four-figure count of probe subprocesses. It is also the wrong bound — a tty still +// cooked this long after its own query never leaves cooked mode, and waiting on it only +// delays a reply that will echo whenever it is sent. +// +// Why wall-clock rather than a probe count: each probe is a subprocess, so a multi-pane +// restore serializes them on fork — a count-based cap measured ~26ms per probe across +// 30 panes, turning a nominal 200ms into ~8s of withholding and blowing past every +// query timeout at once. A deadline spends fewer probes under load instead of taking +// longer, which is the direction that fails safe: measured flat at ~210ms of withholding +// from 1 to 100 concurrent panes, with probe spawns plateauing rather than scaling. +// +// This bounds when a probe is STARTED, not one already in flight, so the hard bound is +// this plus STTY_TIMEOUT_MS — still inside the startup deadline that reset() enforces. +const ECHO_POLL_BUDGET_MS = 200 + +type ExpectedEcho = { projections: readonly string[]; remainingBytes: number } +type PendingWrite = { reply: string; onFailed: (() => void) | undefined } + +/** Only a POSIX tty both echoes the reply and still delivers a deferred write. */ +function defersWrite(ownerBackend: PtyOwnerBackend): boolean { + return ownerBackend === 'posix-pty' +} + +function replyEchoProjections( + reply: string, + ownerBackend: PtyOwnerBackend, + kernelEchoImpossible: boolean +): readonly string[] { + if (ownerBackend === 'windows-conpty') { + // Why: ConPTY's projection is the documented, deterministic ESC-stripped form. + return [reply.replaceAll('\x1b', '')] + } + if (!defersWrite(ownerBackend)) { + // wsl.exe is ConPTY-hosted but its echo shape is unverified; suppress nothing. + return [] + } + // What makes both shapes below safe to match on is that neither starts with ESC, so + // no query can share a prefix with them. The verbatim echo of a `stty -echoctl` tty + // is deliberately NOT projected for exactly that reason: it is byte-identical to the + // reply, so a bare trailing ESC — how any read can end — is a strict prefix of it. + // That read would be held as an echo candidate, and an expired hold releases its + // bytes raw, past the query parser, so a query torn at its own ESC is never answered. + return [ + // ECHOCTL (default cooked tty) renders each control byte as its caret form. This is + // the ONE projection the probe can retire, because it is the kernel's echo and a + // cleared ECHO bit is proof it cannot happen. + ...(kernelEchoImpossible ? [] : [reply.replaceAll('\x1b', '^[')]), + // readline: `ESC ]` is an unbound binding, so it is eaten (with a bell) and the + // remainder self-inserts; the ST is eaten the same way. Software echo — survives + // `quiet`, because readline does this with the tty already raw and ECHO off. + // + // This buys display cleanliness ONLY. The bytes self-inserted into readline's edit + // buffer are still there, so a user who then presses Enter runs them: `bash: 10: + // command not found`, with nothing on screen to explain it. Not fixable by + // suppressing harder — undoing it means writing a kill-line into someone's prompt. + reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', '') + ] +} + +/** Earliest offset whose suffix of `data` is a strict prefix of `projection`, else -1. */ +function suffixPrefixOffset(projection: string, data: string): number { + for ( + let offset = Math.max(0, data.length - projection.length + 1); + offset < data.length; + offset += 1 + ) { + if (projection.startsWith(data.slice(offset))) { + return offset + } + } + return -1 +} + +// Why search the whole span: the tty coalesces its echo with whatever the shell and the +// program wrote around it, so anchoring at offset 0 recognizes almost no real echo. +function locateEcho(projections: readonly string[], data: string): PtyStartupReplyEchoMatch { + let complete: { offset: number; length: number } | null = null + let partialOffset = -1 + for (const projection of projections) { + const at = data.indexOf(projection) + if (at !== -1) { + if (!complete || at < complete.offset) { + complete = { offset: at, length: projection.length } + } + continue + } + const suffix = suffixPrefixOffset(projection, data) + if (suffix !== -1 && (partialOffset === -1 || suffix < partialOffset)) { + partialOffset = suffix + } + } + if (complete) { + return { kind: 'complete', ...complete } + } + return partialOffset === -1 ? { kind: 'none' } : { kind: 'partial', offset: partialOffset } +} + +function isBetterEchoMatch( + candidate: PtyStartupReplyEchoMatch, + best: PtyStartupReplyEchoMatch +): boolean { + if (candidate.kind === 'none') { + return false + } + if (best.kind === 'none') { + return true + } + if (candidate.kind !== best.kind) { + return candidate.kind === 'complete' + } + return candidate.offset < best.offset +} + +/** Owns when a startup color reply is written and how its own echo is recognized. */ +export class PtyStartupReplyDelivery { + private readonly expectedEchoes: ExpectedEcho[] = [] + private readonly pendingWrites: PendingWrite[] = [] + private writeTimer: ReturnType | null = null + private echoPollDeadline = 0 + private closed = false + + constructor( + private readonly ownerBackend: PtyOwnerBackend, + private readonly writeProvider: (data: string) => void, + private readonly echoProbe?: PtySlaveEchoProbe + ) {} + + get hasExpectedEcho(): boolean { + return this.expectedEchoes.length > 0 + } + + /** + * True once the reply has been written or accepted for a later write. + * + * `onFailed` fires only for the second case: a deferred write reports success + * before it happens, so the caller's bookkeeping for THIS reply is a lie if the + * write later throws. Scoped per reply because the replies to one query are + * written independently — one failing says nothing about the ones that landed. + */ + answer(reply: string, onFailed?: () => void): boolean { + if (this.closed) { + return false + } + if (!defersWrite(this.ownerBackend)) { + // Why: ConPTY answers the query itself unless Orca beats it in this turn. + return this.writeReply(reply) + } + // A fresh queue starts a fresh budget, so a second query arriving after the first + // one exhausted its own still gets probed rather than going straight to guessing. + if (this.pendingWrites.length === 0) { + this.echoPollDeadline = Date.now() + ECHO_POLL_BUDGET_MS + } + this.pendingWrites.push({ reply, onFailed }) + this.armWriteTimer() + return true + } + + /** Recognizes any written reply's echo anywhere in the span, earliest match first. */ + matchEcho(data: string): PtyStartupReplyEchoMatch { + let best: PtyStartupReplyEchoMatch = { kind: 'none' } + let bestIndex = -1 + for (const [index, expected] of this.expectedEchoes.entries()) { + const match = locateEcho(expected.projections, data) + if (isBetterEchoMatch(match, best)) { + best = match + bestIndex = index + } + } + if (best.kind === 'complete') { + this.expectedEchoes.splice(bestIndex, 1) + return best + } + return best + } + + /** + * Bytes that went by without completing an echo. Charged by the caller once per PTY + * read rather than per `matchEcho` call, which runs several times over one span. + */ + chargeEchoSearch(byteCount: number): void { + for (let index = this.expectedEchoes.length - 1; index >= 0; index -= 1) { + const expected = this.expectedEchoes[index] + if (!expected) { + continue + } + expected.remainingBytes -= byteCount + if (expected.remainingBytes <= 0) { + this.expectedEchoes.splice(index, 1) + } + } + } + + /** + * Startup window closed. Replies already on the wire stay recognizable, but only + * across the next few hundred bytes: an unbounded projection would keep deleting + * matching spans out of ordinary output for the rest of the session. + */ + reset(): void { + this.flushPendingWrites() + for (const expected of this.expectedEchoes) { + expected.remainingBytes = Math.min(expected.remainingBytes, ECHO_POST_DEADLINE_BUDGET_BYTES) + } + } + + /** Teardown: the pty is gone, so an unwritten reply has nowhere left to go. */ + close(): void { + this.closed = true + this.clearWriteTimer() + this.pendingWrites.length = 0 + this.expectedEchoes.length = 0 + } + + private armWriteTimer(delayMs = 0): void { + if (this.writeTimer) { + return + } + this.writeTimer = setTimeout(() => this.attemptPendingWrites(), delayMs) + this.writeTimer.unref?.() + } + + /** + * Why poll rather than write on the first turn: one deferred turn cannot prove the + * querying program left cooked mode, and the leak happens precisely because Orca + * answered before it got there. Waiting costs the program nothing it is not already + * spending — it is blocked on this reply either way. + */ + private attemptPendingWrites(): void { + this.clearWriteTimer() + if (this.closed || this.pendingWrites.length === 0) { + return + } + if (!this.echoProbe || Date.now() >= this.echoPollDeadline) { + this.flushPendingWrites() + return + } + void this.echoProbe() + .catch(() => 'unknown' as const) + .then((state) => { + if (this.closed || this.pendingWrites.length === 0) { + return + } + if (state === 'echoing') { + this.armWriteTimer(ECHO_POLL_INTERVAL_MS) + return + } + // `quiet` retires the kernel caret projection; `unknown` keeps both shapes. + this.flushPendingWrites(state === 'quiet') + }) + } + + private flushPendingWrites(kernelEchoImpossible = false): void { + this.clearWriteTimer() + for (const pending of this.pendingWrites.splice(0)) { + this.writeReply(pending.reply, pending.onFailed, kernelEchoImpossible) + } + } + + private clearWriteTimer(): void { + if (!this.writeTimer) { + return + } + clearTimeout(this.writeTimer) + this.writeTimer = null + } + + private writeReply(reply: string, onFailed?: () => void, kernelEchoImpossible = false): boolean { + if (this.closed) { + return false + } + const projections = replyEchoProjections(reply, this.ownerBackend, kernelEchoImpossible) + // Why: register before write because node-pty can synchronously re-enter onData. + const expected: ExpectedEcho | null = + projections.length > 0 ? { projections, remainingBytes: ECHO_SEARCH_BUDGET_BYTES } : null + if (expected) { + this.expectedEchoes.push(expected) + } + try { + this.writeProvider(reply) + return true + } catch { + // Why splice by identity, not pop: the write above can re-enter onData and + // retire a different projection, so the last slot is not necessarily ours. + const index = expected ? this.expectedEchoes.indexOf(expected) : -1 + if (index !== -1) { + this.expectedEchoes.splice(index, 1) + } + onFailed?.() + return false + } + } +} diff --git a/src/shared/terminal-mode-reset-profiles.test.ts b/src/shared/terminal-mode-reset-profiles.test.ts new file mode 100644 index 00000000000..bddcd3dbe6b --- /dev/null +++ b/src/shared/terminal-mode-reset-profiles.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { + COLD_RESTORE_SEED_MODE_RESET, + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + POST_REPLAY_LIVE_SNAPSHOT_RESET, + POST_REPLAY_MODE_RESET, + POST_REPLAY_REATTACH_RESET, + RESET_MOUSE_REPORTING, + buildPostReplayLiveAgentReattachReset, + replayPayloadEndsWithCursorHidden +} from './terminal-mode-reset-profiles' + +// Why literal expectations: consumers import these constants, so only a byte-level +// assertion here can catch a profile silently losing a mode it is meant to clear. +describe('terminal mode reset profiles', () => { + it('clears every mouse protocol and encoding a snapshot can re-arm', () => { + expect(RESET_MOUSE_REPORTING).toBe( + '\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l' + ) + }) + + it('pins the fresh-shell profile', () => { + expect(POST_REPLAY_MODE_RESET).toBe( + '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b[?2004l' + ) + }) + + it('pins the daemon-reattach profile, which keeps bracketed paste', () => { + expect(POST_REPLAY_REATTACH_RESET).toBe( + '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l' + ) + expect(POST_REPLAY_REATTACH_RESET).not.toContain('\x1b[?2004l') + }) + + // Why: #12101 — a cold-restored seed re-arms mouse reporting for a dead TUI. + it('disarms mouse reporting on the cold-restore seed', () => { + expect(COLD_RESTORE_SEED_MODE_RESET).toBe(RESET_MOUSE_REPORTING) + }) + + // Why: the seed also feeds the daemon emulator and is re-serialized from it, so + // re-entering alt screen or resetting the cursor there would fight the renderer. + it('keeps the cold-restore seed free of cursor, kitty and alt-screen bytes', () => { + for (const forbidden of ['\x1b[0 q', '\x1b[<99u', '\x1b[?25h', '\x1b[?1049']) { + expect(COLD_RESTORE_SEED_MODE_RESET).not.toContain(forbidden) + } + }) + + // Why byte equality and not just `not.toContain`: a profile that lost every mode + // would satisfy an absence assertion perfectly, so these two — whose only other + // coverage asserts they were passed through unchanged — need a literal here. + it('pins the live-snapshot and live-agent profiles', () => { + expect(POST_REPLAY_LIVE_SNAPSHOT_RESET).toBe('\x1b[0 q\x1b[?25h\x1b[?1004l') + expect(POST_REPLAY_LIVE_AGENT_REATTACH_RESET).toBe('\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h') + expect(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET).toBe('\x1b[0 q') + }) + + it('leaves a live agent its focus reporting and bracketed paste', () => { + for (const profile of [ + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + POST_REPLAY_LIVE_SNAPSHOT_RESET + ]) { + expect(profile).not.toContain('\x1b[?1000l') + expect(profile).not.toContain('\x1b[?2004l') + } + expect(POST_REPLAY_LIVE_AGENT_REATTACH_RESET).not.toContain('\x1b[?1004l') + }) + + describe('live-agent cursor preservation', () => { + it('detects a payload that ends cursor-hidden', () => { + expect(replayPayloadEndsWithCursorHidden('a\x1b[?25hb\x1b[?25lc')).toBe(true) + expect(replayPayloadEndsWithCursorHidden('a\x1b[?25lb\x1b[?25hc')).toBe(false) + expect(replayPayloadEndsWithCursorHidden('no modes here')).toBe(false) + }) + + it('omits the cursor-show when the agent left its cursor hidden', () => { + expect(buildPostReplayLiveAgentReattachReset('x\x1b[?25l')).not.toContain('\x1b[?25h') + expect(buildPostReplayLiveAgentReattachReset('x\x1b[?25h')).toContain('\x1b[?25h') + }) + }) +}) diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts new file mode 100644 index 00000000000..70a01fcd4fd --- /dev/null +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -0,0 +1,41 @@ +// Why this module is shared: these profiles describe a terminal-protocol +// contract, not a renderer concern. Both the renderer (replaying a snapshot +// into an xterm) and the daemon (seeding a cold-restored session) must clear +// the same mode bits, and duplicating the literals drifted them apart (#12101). + +// Why: SerializeAddon replays mode bits assuming reattach to a live TUI, but Orca restores against a fresh shell with none, so stale bits (e.g. focus reporting rings the bell on click) must be reset. +export const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' +export const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u' +// Every mouse mode the daemon can re-arm from a snapshot: protocols 9/1000/1002/1003 + SGR encodings 1006/1016. +export const RESET_MOUSE_REPORTING = + '\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l' + +export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l\x1b[?2004l` + +// Why: same-session live replay; keep cursor/focus cleanup but preserve Kitty flags the running TUI relies on. +export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` + +// Why: daemon reattach hits a live session, so skip the full reset; still clear cursor/focus/mouse/Kitty bits harmful to a plain shell after a bad TUI exit — safe for live TUIs since the post-reattach SIGWINCH repaints the cursor. +export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l` + +// Why: a live agent owns focus reporting; resetting ?1004h suppresses the focus-in it needs to re-anchor its cursor (IME). +export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h` + +// Why: a live agent owns cursor/focus here; forcing ?25h/?1004l breaks a parked agent that only arms ?1004h at startup. +export const POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET = RESET_TERMINAL_CURSOR_STYLE + +/** Dead-TUI bytes feed a fresh shell; clear mouse modes here and renderer-owned modes later. */ +export const COLD_RESTORE_SEED_MODE_RESET = RESET_MOUSE_REPORTING + +// Why: DECTCEM applies in emission order, so the payload's last ?25l/?25h is the cursor state the TUI left. +export function replayPayloadEndsWithCursorHidden(payload: string): boolean { + const hideIndex = payload.lastIndexOf('\x1b[?25l') + return hideIndex !== -1 && hideIndex > payload.lastIndexOf('\x1b[?25h') +} + +// Why: some agents hide the real cursor and draw their own, so preserve the payload's final visibility (pty-connection re-shows it if the agent was actually a dead TUI). +export function buildPostReplayLiveAgentReattachReset(payload: string): string { + return replayPayloadEndsWithCursorHidden(payload) + ? `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}` + : POST_REPLAY_LIVE_AGENT_REATTACH_RESET +} diff --git a/src/shared/terminal-restore-parity-fixture.ts b/src/shared/terminal-restore-parity-fixture.ts index 884eadb14f1..b05b450b90a 100644 --- a/src/shared/terminal-restore-parity-fixture.ts +++ b/src/shared/terminal-restore-parity-fixture.ts @@ -176,9 +176,7 @@ export function normalBufferStylesTrimmed(terminal: Terminal): string[] { export const SNAPSHOT_REPLAY_PREAMBLE_NORMAL = '\x1b[2J\x1b[3J\x1b[H' export const SNAPSHOT_REPLAY_PREAMBLE_ALT = '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H' -// Twin of POST_REPLAY_LIVE_SNAPSHOT_RESET (layout-serialization.ts) — the -// renderer suite pins equality against the real constant so drift fails fast. -export const POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY = '\x1b[0 q\x1b[?25h\x1b[?1004l' +export { POST_REPLAY_LIVE_SNAPSHOT_RESET as POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY } from './terminal-mode-reset-profiles' export type ParityMainSnapshot = { data: string diff --git a/tests/e2e/terminal-attention.spec.ts b/tests/e2e/terminal-attention.spec.ts index 43767d8483b..22e334f53c8 100644 --- a/tests/e2e/terminal-attention.spec.ts +++ b/tests/e2e/terminal-attention.spec.ts @@ -12,7 +12,7 @@ import { waitForSessionReady } from './helpers/store' import { getRendererTitleLog, installRendererTitleLog } from './helpers/terminal-title-log' -import { POST_REPLAY_MODE_RESET } from '../../src/renderer/src/components/terminal-pane/layout-serialization' +import { POST_REPLAY_MODE_RESET } from '../../src/shared/terminal-mode-reset-profiles' import { waitForPtyShellEcho } from './terminal-pty-readiness' test.describe.configure({ mode: 'serial' }) @@ -353,7 +353,7 @@ test.describe('Terminal attention', () => { // even though the underlying shell is fresh. Pane clicks then emit // `\e[I` / `\e[O` into zsh, which rings the bell as unbound-key input. // - // POST_REPLAY_MODE_RESET (in layout-serialization.ts) clears these mode + // POST_REPLAY_MODE_RESET (in shared/terminal-mode-reset-profiles.ts) clears these mode // bits after every scrollback replay so the mode state matches the fresh // shell. This test pins that fix: after writing a DECSET 1004 byte into // the terminal, focus events should NOT be emitted back to the PTY. From 3d6d6dd67d87717e527358e27743f4df3120f30b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=98=81=ED=83=9D?= <89302528+Jaeyoung22@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:21:03 +0900 Subject: [PATCH 027/101] fix(orchestration): scope agent lineage to its owning run (#11203) Use durable Task-to-Run ownership and current pane, process-incarnation, and Run-generation authority for sidebar agent lineage. Add schema migrations, bounded lookup indexes, fail-closed renderer cleanup, and runtime/RPC regression coverage. Co-authored-by: Jaeyoung22 <89302528+Jaeyoung22@users.noreply.github.com> --- config/reliability-gates.jsonc | 15 +- src/main/runtime/orca-runtime.test.ts | 522 +++++++++++++++++- src/main/runtime/orca-runtime.ts | 102 +++- .../runtime/orchestration/coordinator.test.ts | 36 ++ src/main/runtime/orchestration/coordinator.ts | 19 +- src/main/runtime/orchestration/db.ts | 102 +++- ...tion-creator-authority-performance.test.ts | 156 ++++++ ...hestration-db-retention-pagination.test.ts | 86 ++- src/main/runtime/orchestration/types.ts | 3 + .../runtime/rpc/methods/orchestration.test.ts | 47 +- src/main/runtime/rpc/methods/orchestration.ts | 34 +- ...hestration-legacy-coordinator-race.test.ts | 10 + .../src/store/slices/agent-status.test.ts | 36 ++ src/renderer/src/store/slices/agent-status.ts | 3 + 14 files changed, 1118 insertions(+), 53 deletions(-) create mode 100644 src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c84075e6c04..c4f70fb7aea 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -6646,15 +6646,16 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "ssh"], - "coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.", + "coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, creator pane/process/Run-generation fencing, indexed retained-Run lookup scaling, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.", "motivatingLinks": ["https://github.com/stablyai/orca/pull/11107#discussion_r3663321387"], - "invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata, explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.", - "oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.", + "invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.", + "oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately remint a creator pane and process from Run A into Run B, require the nested Run A worker to fall back to its current coordinator, require indexed query plans, and bound 300 Task reads with 50,000 retained Runs. They also assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", "pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1" @@ -6663,6 +6664,7 @@ "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts", "src/main/runtime/orchestration/formatter.test.ts", "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", + "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/cli/handlers/orchestration.test.ts", "src/cli/handlers/orchestration-check-identity.test.ts", @@ -6684,6 +6686,13 @@ "ordinary mail and remote attachments use the same attested pane and process authority" ] }, + { + "file": "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", + "assertions": [ + "creator lookup uses the assignee-handle and pane-leaf indexes without a retained-Run scan", + "300 Task reads remain bounded with 50,000 unrelated retained Runs" + ] + }, { "file": "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", "assertions": [ diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d8c94367098..3aa249a0d99 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -37942,6 +37942,7 @@ describe('OrcaRuntimeService', () => { handle === workerHandle ? { id: 'ctx-1', + run_id: 'run-1', task_id: 'task-1', assignee_handle: workerHandle, status: 'dispatched' @@ -37952,6 +37953,7 @@ describe('OrcaRuntimeService', () => { handle === workerHandle ? { id: 'ctx-done', + run_id: 'run-1', task_id: 'task-done', assignee_handle: workerHandle, status: 'completed', @@ -37961,14 +37963,16 @@ describe('OrcaRuntimeService', () => { ), getTask: vi.fn(() => ({ id: 'task-1', + run_id: 'run-1', task_title: 'Dispatch prompt work', display_name: 'Review dispatch prompts and make worker labels distinct', spec: 'Review dispatch prompts\n\nand make worker labels distinct', created_by_terminal_handle: coordinatorHandle })), - getActiveCoordinatorRun: vi.fn(() => ({ + getRun: vi.fn(() => ({ id: 'run-1', - coordinator_handle: coordinatorHandle + coordinator_handle: coordinatorHandle, + legacy: 0 })) } as never) runtime.attachWindow(1) @@ -38023,6 +38027,513 @@ describe('OrcaRuntimeService', () => { }) }) + it.each([ + ['fails closed when a modern dispatch owning Run is missing', 'run-missing', 'run-missing'], + ['fails closed when Task and Dispatch Runs disagree', 'run-dispatch', 'run-task'] + ])('%s', (_name, dispatchRunId, taskRunId) => { + const runtime = new OrcaRuntimeService(store) + const workerLeafId = '77777777-7777-4777-8777-777777777777' + const coordinatorLeafId = '88888888-8888-4888-8888-888888888888' + const workerPaneKey = makePaneKey('tab-worker', workerLeafId) + const workerHandle = runtime.preAllocateHandleForPty('pty-worker') + const coordinatorHandle = runtime.preAllocateHandleForPty('pty-coordinator') + const getActiveCoordinatorRun = vi.fn(() => ({ + id: 'run-legacy-unrelated', + coordinator_handle: coordinatorHandle + })) + runtime.setOrchestrationDb({ + getActiveDispatchForTerminal: vi.fn((handle: string) => + handle === workerHandle + ? { + id: 'ctx-missing-run', + run_id: dispatchRunId, + task_id: 'task-missing-run', + assignee_handle: workerHandle, + status: 'dispatched' + } + : undefined + ), + getLatestDispatchForTerminal: vi.fn(() => undefined), + getTask: vi.fn(() => ({ + id: 'task-missing-run', + run_id: taskRunId, + spec: 'modern task without proven Run', + created_by_terminal_handle: coordinatorHandle + })), + getRun: vi.fn(() => undefined), + getActiveCoordinatorRun + } as never) + runtime.attachWindow(1) + + const result = runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-worker', + worktreeId: TEST_WORKTREE_ID, + title: 'Worker', + activeLeafId: workerLeafId, + layout: null + }, + { + tabId: 'tab-coordinator', + worktreeId: TEST_WORKTREE_ID, + title: 'Coordinator', + activeLeafId: coordinatorLeafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-worker', + worktreeId: TEST_WORKTREE_ID, + leafId: workerLeafId, + paneRuntimeId: 1, + ptyId: 'pty-worker', + paneTitle: null + }, + { + tabId: 'tab-coordinator', + worktreeId: TEST_WORKTREE_ID, + leafId: coordinatorLeafId, + paneRuntimeId: 2, + ptyId: 'pty-coordinator', + paneTitle: null + } + ] + }) + + expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toEqual({ + taskId: 'task-missing-run', + dispatchId: 'ctx-missing-run', + dispatchStatus: 'dispatched', + taskTitle: 'modern task without proven Run', + displayName: 'modern task without proven Run' + }) + expect(getActiveCoordinatorRun).not.toHaveBeenCalled() + }) + + it('uses durable Run ownership before worktree-scoped legacy attribution', () => { + const childWorktreeId = `${TEST_REPO_ID}::${join(tmpdir(), 'workspaces', 'run-a-worker')}` + const folderWorktreeId = `${TEST_REPO_ID}::${join(tmpdir(), 'folder')}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}11111111-1111-4111-8111-111111111111` + const meta = store.getAllWorktreeMeta()[TEST_WORKTREE_ID] + const metaById = { + ...store.getAllWorktreeMeta(), + [childWorktreeId]: meta, + [folderWorktreeId]: meta + } + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } as never) + const terminals = [ + { + name: 'coordinator-a', + worktreeId: TEST_WORKTREE_ID, + leafId: '11111111-1111-4111-8111-111111111111' + }, + { + name: 'coordinator-b', + worktreeId: TEST_WORKTREE_ID, + leafId: '22222222-2222-4222-8222-222222222222' + }, + { + name: 'worker-cross-worktree', + worktreeId: childWorktreeId, + leafId: '33333333-3333-4333-8333-333333333333' + }, + { + name: 'worker-same-worktree', + worktreeId: TEST_WORKTREE_ID, + leafId: '44444444-4444-4444-8444-444444444444' + }, + { + name: 'worker-folder', + worktreeId: folderWorktreeId, + leafId: '55555555-5555-4555-8555-555555555555' + }, + { + name: 'legacy-worker', + worktreeId: childWorktreeId, + leafId: '66666666-6666-4666-8666-666666666666' + } + ].map((terminal, index) => ({ + ...terminal, + tabId: `tab-${terminal.name}`, + ptyId: `pty-${terminal.name}`, + paneRuntimeId: index + 1 + })) + const terminalByName = Object.fromEntries( + terminals.map((terminal) => [terminal.name, terminal]) + ) + const handles = Object.fromEntries( + terminals.map((terminal) => [terminal.name, runtime.preAllocateHandleForPty(terminal.ptyId)]) + ) + const paneKey = (name: string): string => { + const terminal = terminalByName[name] + return makePaneKey(terminal.tabId, terminal.leafId) + } + const db = new OrchestrationDb(':memory:') + try { + const runA = db.createRun({ + objective: 'coordinate run A', + coordinatorHandle: handles['coordinator-a'], + coordinatorPaneKey: paneKey('coordinator-a') + }) + const runB = db.createRun({ + objective: 'coordinate run B', + coordinatorHandle: handles['coordinator-b'], + coordinatorPaneKey: paneKey('coordinator-b') + }) + const dispatches = Object.fromEntries( + [ + ['worker-cross-worktree', runA.id], + ['worker-same-worktree', runA.id], + ['worker-folder', runB.id] + ].map(([name, runId]) => { + const task = db.createTask({ spec: name, runId }) + return [name, db.createDispatchContext(task.id, handles[name], paneKey(name))] + }) + ) + const legacyTask = db.createTask({ spec: 'legacy worker' }) + const legacyDispatch = db.createDispatchContext( + legacyTask.id, + handles['legacy-worker'], + paneKey('legacy-worker') + ) + db.createCoordinatorRun({ + spec: 'unrelated legacy coordinator', + coordinatorHandle: handles['coordinator-b'] + }) + const getActiveCoordinatorRun = vi.spyOn(db, 'getActiveCoordinatorRun') + runtime.setOrchestrationDb(db) + runtime.attachWindow(1) + + const result = runtime.syncWindowGraph(1, { + tabs: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: terminal.worktreeId, + title: terminal.name, + activeLeafId: terminal.leafId, + layout: null + })), + leaves: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: terminal.worktreeId, + leafId: terminal.leafId, + paneRuntimeId: terminal.paneRuntimeId, + ptyId: terminal.ptyId, + paneTitle: null + })) + }) + + for (const [name, run, coordinator] of [ + ['worker-cross-worktree', runA, 'coordinator-a'], + ['worker-same-worktree', runA, 'coordinator-a'], + ['worker-folder', runB, 'coordinator-b'] + ] as const) { + expect(result.agentOrchestrationByPaneKey?.[paneKey(name)]).toMatchObject({ + taskId: dispatches[name].task_id, + dispatchId: dispatches[name].id, + dispatchStatus: 'dispatched', + parentTerminalHandle: handles[coordinator], + parentPaneKey: paneKey(coordinator), + coordinatorHandle: handles[coordinator], + orchestrationRunId: run.id + }) + } + const legacyContext = result.agentOrchestrationByPaneKey?.[paneKey('legacy-worker')] + expect(legacyContext).toMatchObject({ + taskId: legacyTask.id, + dispatchId: legacyDispatch.id, + dispatchStatus: 'dispatched' + }) + expect(legacyContext).not.toHaveProperty('parentTerminalHandle') + expect(legacyContext).not.toHaveProperty('coordinatorHandle') + expect(legacyContext).not.toHaveProperty('orchestrationRunId') + expect(getActiveCoordinatorRun).toHaveBeenCalledOnce() + } finally { + db.close() + } + }) + + it('uses the still-bound owning Run coordinator after a creator pane rebinds', () => { + const runtime = new OrcaRuntimeService(store) + const terminals = [ + { + name: 'coordinator', + leafId: '11111111-1111-4111-8111-111111111111' + }, + { + name: 'creator', + leafId: '22222222-2222-4222-8222-222222222222' + }, + { + name: 'worker', + leafId: '33333333-3333-4333-8333-333333333333' + }, + { + name: 'coordinator-created-worker', + leafId: '44444444-4444-4444-8444-444444444444' + } + ].map((terminal, index) => ({ + ...terminal, + tabId: `tab-${terminal.name}`, + ptyId: `pty-${terminal.name}`, + paneRuntimeId: index + 1 + })) + const terminalByName = Object.fromEntries( + terminals.map((terminal) => [terminal.name, terminal]) + ) + const handles = Object.fromEntries( + terminals.map((terminal) => [terminal.name, runtime.preAllocateHandleForPty(terminal.ptyId)]) + ) + const paneKey = (name: string): string => { + const terminal = terminalByName[name] + return makePaneKey(terminal.tabId, terminal.leafId) + } + const graph = () => ({ + tabs: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: TEST_WORKTREE_ID, + title: terminal.name, + activeLeafId: terminal.leafId, + layout: null + })), + leaves: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: terminal.leafId, + paneRuntimeId: terminal.paneRuntimeId, + ptyId: terminal.ptyId, + paneTitle: null + })) + }) + const db = new OrchestrationDb(':memory:') + try { + runtime.setOrchestrationDb(db) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, graph()) + const runA = db.createRun({ + objective: 'own the nested worker', + coordinatorHandle: handles.coordinator, + coordinatorPaneKey: paneKey('coordinator') + }) + const creatorAuthority = runtime.getOrchestrationDispatchAuthority(handles.creator) + const coordinatorAuthority = runtime.getOrchestrationDispatchAuthority(handles.coordinator) + expect(creatorAuthority?.processIncarnation).toBeTruthy() + expect(coordinatorAuthority?.processIncarnation).toBeTruthy() + const creatorTask = db.createTask({ spec: 'create nested work', runId: runA.id }) + db.createDispatchContext( + creatorTask.id, + handles.creator, + paneKey('creator'), + undefined, + creatorAuthority?.processIncarnation ?? undefined + ) + const workerTask = db.createTask({ + spec: 'nested work', + runId: runA.id, + createdByTerminalHandle: handles.creator, + createdByPaneKey: paneKey('creator'), + createdByProcessIncarnation: creatorAuthority?.processIncarnation ?? undefined, + createdByRunGeneration: runA.consumer_generation + }) + const workerDispatch = db.createDispatchContext( + workerTask.id, + handles.worker, + paneKey('worker') + ) + const coordinatorCreatedTask = db.createTask({ + spec: 'coordinator-created work', + runId: runA.id, + createdByTerminalHandle: handles.coordinator, + createdByPaneKey: paneKey('coordinator'), + createdByProcessIncarnation: coordinatorAuthority?.processIncarnation ?? undefined, + createdByRunGeneration: runA.consumer_generation + }) + const coordinatorCreatedDispatch = db.createDispatchContext( + coordinatorCreatedTask.id, + handles['coordinator-created-worker'], + paneKey('coordinator-created-worker') + ) + expect( + runtime.syncWindowGraph(1, graph()).agentOrchestrationByPaneKey?.[paneKey('worker')] + ).toMatchObject({ + parentTerminalHandle: handles.creator, + parentPaneKey: paneKey('creator'), + coordinatorHandle: handles.coordinator, + orchestrationRunId: runA.id + }) + + const oldCreatorPaneKey = paneKey('creator') + terminalByName.creator.tabId = 'tab-creator-reminted' + terminalByName.creator.ptyId = 'pty-creator-reminted' + const remintedCreatorHandle = runtime.preAllocateHandleForPty(terminalByName.creator.ptyId) + runtime.syncWindowGraph(1, graph()) + const runB = db.createRun({ + objective: 'rebind the creator pane', + coordinatorHandle: remintedCreatorHandle, + coordinatorPaneKey: paneKey('creator') + }) + const reboundContext = runtime.syncWindowGraph(1, graph()).agentOrchestrationByPaneKey?.[ + paneKey('worker') + ] + + expect(db.getRun(runA.id)).toMatchObject({ + coordinator_handle: handles.coordinator, + consumer_generation: 1 + }) + expect(oldCreatorPaneKey).not.toBe(paneKey('creator')) + expect(db.getRun(runB.id)).toMatchObject({ coordinator_handle: remintedCreatorHandle }) + expect(reboundContext).toMatchObject({ + taskId: workerTask.id, + dispatchId: workerDispatch.id, + dispatchStatus: 'dispatched', + parentTerminalHandle: handles.coordinator, + parentPaneKey: paneKey('coordinator'), + coordinatorHandle: handles.coordinator, + orchestrationRunId: runA.id + }) + + db.createRun({ + objective: 'rebind the original coordinator pane', + coordinatorHandle: handles.coordinator, + coordinatorPaneKey: paneKey('coordinator') + }) + const unboundContext = runtime.syncWindowGraph(1, graph()).agentOrchestrationByPaneKey?.[ + paneKey('coordinator-created-worker') + ] + + expect(db.getRun(runA.id)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + consumer_generation: 2 + }) + expect(unboundContext).toEqual({ + taskId: coordinatorCreatedTask.id, + dispatchId: coordinatorCreatedDispatch.id, + dispatchStatus: 'dispatched', + taskTitle: 'coordinator-created work', + displayName: 'coordinator-created work', + orchestrationRunId: runA.id + }) + } finally { + db.close() + } + }) + + it('queries each stable terminal handle once while publishing orchestration context', () => { + const runtime = new OrcaRuntimeService(store) + const terminals = Array.from({ length: 100 }, (_, index) => ({ + tabId: `tab-query-${index}`, + leafId: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + ptyId: `pty-query-${index}`, + paneRuntimeId: index + 1 + })) + const handles = terminals.map((terminal) => runtime.preAllocateHandleForPty(terminal.ptyId)) + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'query count oracle', + coordinatorHandle: handles[99], + coordinatorPaneKey: makePaneKey(terminals[99].tabId, terminals[99].leafId) + }) + const task = db.createTask({ spec: 'one dispatched terminal', runId: run.id }) + const dispatch = db.createDispatchContext( + task.id, + handles[0], + makePaneKey(terminals[0].tabId, terminals[0].leafId) + ) + const getActiveDispatchForTerminal = vi.spyOn(db, 'getActiveDispatchForTerminal') + const getLatestDispatchForTerminal = vi.spyOn(db, 'getLatestDispatchForTerminal') + const getTask = vi.spyOn(db, 'getTask') + const getRun = vi.spyOn(db, 'getRun') + const getActiveCoordinatorRun = vi.spyOn(db, 'getActiveCoordinatorRun') + runtime.setOrchestrationDb(db) + runtime.attachWindow(1) + + const graph = { + tabs: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: TEST_WORKTREE_ID, + title: terminal.tabId, + activeLeafId: terminal.leafId, + layout: null + })), + leaves: terminals.map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: terminal.leafId, + paneRuntimeId: terminal.paneRuntimeId, + ptyId: terminal.ptyId, + paneTitle: null + })) + } + runtime.syncWindowGraph(1, graph) + + const queryCounts = { + activeDispatch: getActiveDispatchForTerminal.mock.calls.length, + latestDispatch: getLatestDispatchForTerminal.mock.calls.length, + task: getTask.mock.calls.length, + run: getRun.mock.calls.length, + legacyCoordinator: getActiveCoordinatorRun.mock.calls.length + } + + db.completeDispatch(dispatch.id) + vi.useFakeTimers() + vi.setSystemTime(Date.now() + AGENT_STATUS_STALE_AFTER_MS + 5_000) + for (const query of [ + getActiveDispatchForTerminal, + getLatestDispatchForTerminal, + getTask, + getRun, + getActiveCoordinatorRun + ]) { + query.mockClear() + } + runtime.syncWindowGraph(1, graph) + + const historicalQueryCounts = { + activeDispatch: getActiveDispatchForTerminal.mock.calls.length, + latestDispatch: getLatestDispatchForTerminal.mock.calls.length, + task: getTask.mock.calls.length, + run: getRun.mock.calls.length, + legacyCoordinator: getActiveCoordinatorRun.mock.calls.length + } + expect({ + active: { + ...queryCounts, + total: Object.values(queryCounts).reduce((sum, n) => sum + n) + }, + historical: { + ...historicalQueryCounts, + total: Object.values(historicalQueryCounts).reduce((sum, n) => sum + n) + } + }).toEqual({ + active: { + activeDispatch: 100, + latestDispatch: 99, + task: 1, + run: 1, + legacyCoordinator: 0, + total: 201 + }, + historical: { + activeDispatch: 100, + latestDispatch: 100, + task: 0, + run: 0, + legacyCoordinator: 0, + total: 200 + } + }) + } finally { + vi.useRealTimers() + db.close() + } + }) + it('returns completed orchestration context for renderer-synced terminal leaves', () => { const runtime = new OrcaRuntimeService(store) const workerLeafId = '33333333-3333-4333-8333-333333333333' @@ -38037,6 +38548,7 @@ describe('OrcaRuntimeService', () => { handle === workerHandle ? { id: 'ctx-done', + run_id: 'run-1', task_id: 'task-done', assignee_handle: workerHandle, status: 'completed', @@ -38046,7 +38558,13 @@ describe('OrcaRuntimeService', () => { ), getTask: vi.fn(() => ({ id: 'task-done', + run_id: 'run-1', created_by_terminal_handle: coordinatorHandle + })), + getRun: vi.fn(() => ({ + id: 'run-1', + coordinator_handle: coordinatorHandle, + legacy: 0 })) } as never) runtime.attachWindow(1) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8cca2232985..c94d222de37 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -29977,11 +29977,13 @@ export class OrcaRuntimeService { return undefined } const contexts: Record = {} + const queriedHandles = new Set() for (const leaf of this.leaves.values()) { if (!leaf.ptyId) { continue } const handle = this.issueHandle(leaf) + queriedHandles.add(handle) const context = this.getAgentStatusOrchestrationContextForHandle(handle, db) if (context) { contexts[this.makeRuntimePaneKey(leaf)] = context @@ -29992,6 +29994,10 @@ export class OrcaRuntimeService { continue } const handle = this.issuePtyHandle(pty) + if (queriedHandles.has(handle)) { + continue + } + queriedHandles.add(handle) const context = this.getAgentStatusOrchestrationContextForHandle(handle, db) if (context) { contexts[pty.paneKey] = context @@ -30011,7 +30017,7 @@ export class OrcaRuntimeService { if (!dispatch) { return undefined } - const task = db?.getTask?.(dispatch.task_id) + const task = db?.getTask?.(dispatch.task_id, dispatch.run_id) const display = typeof task?.spec === 'string' ? buildOrchestrationTaskDisplayMetadata({ @@ -30020,15 +30026,68 @@ export class OrcaRuntimeService { displayName: task.display_name }) : { taskTitle: '', displayName: '' } - const activeRun = - dispatch.status === 'pending' || dispatch.status === 'dispatched' + const owningRun = + task?.run_id && task.run_id === dispatch.run_id ? db?.getRun?.(dispatch.run_id) : undefined + const runCoordinatorHandle = owningRun?.coordinator_handle ?? undefined + const legacyActiveRun = + owningRun?.legacy === 1 && (dispatch.status === 'pending' || dispatch.status === 'dispatched') ? db?.getActiveCoordinatorRun?.() : undefined + // Why: legacy coordinator runs have no durable task ownership, so fail closed across worktrees. + const handleWorktreeId = legacyActiveRun ? this.getWorktreeIdForTerminalHandle(handle) : null + const legacyCoordinatorWorktreeId = legacyActiveRun + ? this.getWorktreeIdForTerminalHandle(legacyActiveRun.coordinator_handle) + : null + const scopedLegacyActiveRun = + legacyActiveRun && + handleWorktreeId && + legacyCoordinatorWorktreeId && + runtimeWorktreeIdsEqual(legacyCoordinatorWorktreeId, handleWorktreeId) + ? legacyActiveRun + : undefined + const coordinatorHandle = runCoordinatorHandle ?? scopedLegacyActiveRun?.coordinator_handle + const orchestrationRunId = owningRun?.legacy === 0 ? owningRun.id : scopedLegacyActiveRun?.id + const creatorPaneKey = task?.created_by_pane_key + const creatorPaneHandle = creatorPaneKey + ? this.getTerminalHandleForPaneKey(creatorPaneKey) + : null + const creatorAuthority = creatorPaneHandle + ? this.getOrchestrationDispatchAuthority(creatorPaneHandle) + : null + const storedCreatorPane = creatorPaneKey ? parsePaneKey(creatorPaneKey) : null + const currentCreatorPane = creatorAuthority?.paneKey + ? parsePaneKey(creatorAuthority.paneKey) + : null + const sameCreatorPane = Boolean( + creatorPaneKey && + creatorAuthority?.paneKey && + (creatorPaneKey === creatorAuthority.paneKey || + (storedCreatorPane && + currentCreatorPane && + storedCreatorPane.leafId === currentCreatorPane.leafId)) + ) + const paneRun = creatorPaneKey ? db?.getCurrentRunForPane?.(creatorPaneKey) : undefined + const sameRunCreatorDispatch = Boolean( + task?.creator_dispatch_id && + task.creator_dispatch_run_id === owningRun?.id && + task.creator_dispatch_pane_key && + task.creator_dispatch_process_incarnation === task.created_by_process_incarnation && + parsePaneKey(task.creator_dispatch_pane_key)?.leafId === storedCreatorPane?.leafId + ) + const currentCreatorHandle = + owningRun?.legacy === 0 && + task?.created_by_run_generation === owningRun.consumer_generation && + task.created_by_process_incarnation === creatorAuthority?.processIncarnation && + sameCreatorPane && + (paneRun + ? paneRun.id === owningRun.id && + paneRun.consumer_generation === task.created_by_run_generation + : sameRunCreatorDispatch) + ? (creatorPaneHandle ?? undefined) + : undefined const parentTerminalHandle = - task?.created_by_terminal_handle ?? - (activeRun?.coordinator_handle && activeRun.coordinator_handle !== handle - ? activeRun.coordinator_handle - : undefined) + currentCreatorHandle ?? + (coordinatorHandle && coordinatorHandle !== handle ? coordinatorHandle : undefined) const parentPaneKey = parentTerminalHandle ? this.getPaneKeyForTerminalHandle(parentTerminalHandle) : undefined @@ -30041,8 +30100,8 @@ export class OrcaRuntimeService { ...(display.displayName ? { displayName: display.displayName } : {}), ...(parentTerminalHandle ? { parentTerminalHandle } : {}), ...(parentPaneKey ? { parentPaneKey } : {}), - ...(activeRun?.coordinator_handle ? { coordinatorHandle: activeRun.coordinator_handle } : {}), - ...(activeRun?.id ? { orchestrationRunId: activeRun.id } : {}) + ...(coordinatorHandle ? { coordinatorHandle } : {}), + ...(orchestrationRunId ? { orchestrationRunId } : {}) } } @@ -30095,10 +30154,21 @@ export class OrcaRuntimeService { return pty } leafPty = pty ?? null + for (const candidate of this.leaves.values()) { + if (candidate.leafId !== parsed.leafId || !candidate.ptyId) { + continue + } + const remintedPty = this.ptysById.get(candidate.ptyId) + if (remintedPty?.connected) { + return remintedPty + } + leafPty ??= remintedPty ?? null + } } let newestMatch: RuntimePtyWorktreeRecord | null = null for (const pty of this.ptysById.values()) { - if (pty.paneKey === paneKey) { + const ptyPane = parsePaneKey(pty.paneKey ?? '') + if (pty.paneKey === paneKey || (parsed && ptyPane && parsed.leafId === ptyPane.leafId)) { if (pty.connected) { return pty } @@ -30123,6 +30193,18 @@ export class OrcaRuntimeService { return makePaneKey(record.tabId, record.leafId) } + private getWorktreeIdForTerminalHandle(handle: string): string | null { + const livePty = this.getLivePtyForHandle(handle) + if (livePty?.pty.worktreeId) { + return livePty.pty.worktreeId + } + const record = this.handles.get(handle) + if (!record || record.runtimeId !== this.runtimeId) { + return null + } + return record.worktreeId + } + private setPtyManagementTitleFromObservedTitle( pty: RuntimePtyWorktreeRecord, title: string | null | undefined, diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 31e9180c93e..cbb3c3f8011 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -177,6 +177,42 @@ describe('Coordinator', () => { }) expect(db.getDispatchContext(task.id)?.assignee_pane_key).toBe('tab_a:leaf_a') + expect(db.getDispatchContext(task.id)?.process_incarnation).toBeNull() + + insertWorkerDone(db, { taskId: task.id }) + await runPromise + }) + + it('records authenticated process authority for automatic dispatch', async () => { + db = new OrchestrationDb(':memory:') + const runtime = createMockRuntime() + runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] + const withAuthority = Object.assign(runtime, { + getOrchestrationDispatchAuthority: (handle: string) => + handle === 'term_a' + ? { + paneKey: 'tab_a:leaf_a', + processIncarnation: 'pty_a:incarnation-a', + launchTokenHash: 'launch-token-hash' + } + : null + }) + const task = db.createTask({ spec: 'implement feature' }) + const coordinator = new Coordinator(db, withAuthority, { + spec: 'build it', + coordinatorHandle: 'coord', + pollIntervalMs: 50 + }) + const runPromise = coordinator.run() + await new Promise((r) => { + setTimeout(r, 100) + }) + + expect(db.getDispatchContext(task.id)).toMatchObject({ + assignee_pane_key: 'tab_a:leaf_a', + process_incarnation: 'pty_a:incarnation-a', + launch_token_hash: 'launch-token-hash' + }) insertWorkerDone(db, { taskId: task.id }) await runPromise diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 7fcd5288f5d..df69ed9ef7b 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -26,8 +26,14 @@ export type CoordinatorRuntime = { behind: number recentSubjects: string[] } | null> - // Why: optional so lightweight runtime fakes keep compiling; when present, dispatch records the assignee's remint-stable pane identity. + // Why: pane-only fallback preserves reservation identity for lightweight runtime fakes. getTerminalPaneKey?(handle: string): string | null + // Why: automatic dispatch persists the same authenticated pane/process tuple as manual dispatch. + getOrchestrationDispatchAuthority?(handle: string): { + paneKey: string | null + processIncarnation: string | null + launchTokenHash: string | null + } | null // Why: Windows can host native and WSL workers at once, so the worker pane (not the coordinator) picks the packaged CLI name. getTerminalOrchestrationCliCommand?(handle: string): 'orca' | 'orca-ide' } @@ -419,10 +425,19 @@ export class Coordinator { } } + const dispatchAuthority = this.runtime.getOrchestrationDispatchAuthority?.(targetHandle) + const assigneePaneKey = + dispatchAuthority?.paneKey ?? this.runtime.getTerminalPaneKey?.(targetHandle) ?? undefined + const processIncarnation = + dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation + ? dispatchAuthority.processIncarnation + : undefined const dispatch = this.db.createDispatchContext( task.id, targetHandle, - this.runtime.getTerminalPaneKey?.(targetHandle) ?? undefined + assigneePaneKey, + dispatchAuthority?.launchTokenHash ?? undefined, + processIncarnation ) // Why: dispatched agents use orca-dev in dev mode to reach the dev runtime's socket, not production (Section 6.4). diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 5cdfccc31e5..3608ee7d754 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -66,8 +66,10 @@ function isEquivalentPaneKey(a: string, b: string): boolean { // Why: indexable pre-filter for isEquivalentPaneKey — equal strings and equal leaves both share the // text after the first ':', so this narrows candidates without deciding equivalence itself. -const PANE_KEY_MATCH_SUFFIX_SQL = +const RUN_PANE_KEY_MATCH_SUFFIX_SQL = "substr(coordinator_pane_key, instr(coordinator_pane_key, ':') + 1)" +const DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL = + "substr(assignee_pane_key, instr(assignee_pane_key, ':') + 1)" function paneKeyMatchSuffix(paneKey: string): string { const colon = paneKey.indexOf(':') @@ -263,13 +265,20 @@ export type RunListPage = { nextCursor: string | null } +export type TaskRuntimeLineageRow = TaskRow & { + creator_dispatch_id: string | null + creator_dispatch_run_id: string | null + creator_dispatch_pane_key: string | null + creator_dispatch_process_incarnation: string | null +} + type RunListCursor = { createdAt: string id: string } -// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership. -const SCHEMA_VERSION = 23 +// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup. +const SCHEMA_VERSION = 25 function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { if (dbPath === ':memory:' || process.platform === 'win32') { @@ -517,6 +526,9 @@ export class OrchestrationDb { run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}', parent_id TEXT, created_by_terminal_handle TEXT, + created_by_pane_key TEXT, + created_by_process_incarnation TEXT, + created_by_run_generation INTEGER, task_title TEXT, display_name TEXT, spec TEXT NOT NULL, @@ -576,7 +588,7 @@ export class OrchestrationDb { CREATE INDEX IF NOT EXISTS idx_gates_status ON decision_gates(status); CREATE INDEX IF NOT EXISTS idx_runs_coordinator_pane_leaf - ON runs(${PANE_KEY_MATCH_SUFFIX_SQL}) + ON runs(${RUN_PANE_KEY_MATCH_SUFFIX_SQL}) WHERE coordinator_pane_key IS NOT NULL; CREATE TABLE IF NOT EXISTS coordinator_runs ( @@ -949,6 +961,29 @@ export class OrchestrationDb { if (current < 23) { this.backfillWorkerTerminalResources() } + if (current < 24) { + if (!this.hasColumn('tasks', 'created_by_pane_key')) { + this.db.exec('ALTER TABLE tasks ADD COLUMN created_by_pane_key TEXT') + } + if (!this.hasColumn('tasks', 'created_by_process_incarnation')) { + this.db.exec('ALTER TABLE tasks ADD COLUMN created_by_process_incarnation TEXT') + } + if (!this.hasColumn('tasks', 'created_by_run_generation')) { + this.db.exec('ALTER TABLE tasks ADD COLUMN created_by_run_generation INTEGER') + } + } + if (current < 25) { + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_dispatch_active_assignee_handle + ON dispatch_contexts(assignee_handle) + WHERE assignee_handle IS NOT NULL AND status IN ('pending', 'dispatched'); + `) + } + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf + ON dispatch_contexts(${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL}) + WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched'); + `) this.createUndeliveredInboxIndexIfPossible() this.db.pragma(`user_version = ${SCHEMA_VERSION}`) @@ -2423,7 +2458,7 @@ export class OrchestrationDb { .prepare( `SELECT * FROM runs WHERE coordinator_pane_key IS NOT NULL AND legacy = 0 - AND ${PANE_KEY_MATCH_SUFFIX_SQL} = ? + AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ? ORDER BY rowid` ) .all(paneKeyMatchSuffix(paneKey)) as RunRow[] @@ -3743,6 +3778,9 @@ export class OrchestrationDb { deps?: string[] parentId?: string createdByTerminalHandle?: string + createdByPaneKey?: string + createdByProcessIncarnation?: string + createdByRunGeneration?: number runId?: string }): TaskRow { const runId = task.runId ?? LEGACY_RUN_ID @@ -3770,13 +3808,20 @@ export class OrchestrationDb { }) this.db .prepare( - 'INSERT INTO tasks (id, run_id, parent_id, created_by_terminal_handle, task_title, display_name, spec, status, deps) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + `INSERT INTO tasks ( + id, run_id, parent_id, created_by_terminal_handle, created_by_pane_key, + created_by_process_incarnation, created_by_run_generation, + task_title, display_name, spec, status, deps + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, runId, task.parentId ?? null, task.createdByTerminalHandle ?? null, + task.createdByPaneKey ?? null, + task.createdByProcessIncarnation ?? null, + task.createdByRunGeneration ?? null, display.taskTitle || null, display.displayName || null, task.spec, @@ -3786,8 +3831,33 @@ export class OrchestrationDb { return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow } - getTask(id: string): TaskRow | undefined { - return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined + // Why: return the active creator Dispatch proof with the Task read; runtime still owns pane/process currency. + getTask(id: string): TaskRow | undefined + getTask(id: string, dispatchRunId: string): TaskRuntimeLineageRow | undefined + getTask(id: string, dispatchRunId?: string): TaskRow | TaskRuntimeLineageRow | undefined { + if (dispatchRunId === undefined) { + return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined + } + return this.db + .prepare( + `SELECT t.*, + creator.id AS creator_dispatch_id, + creator.run_id AS creator_dispatch_run_id, + creator.assignee_pane_key AS creator_dispatch_pane_key, + creator.process_incarnation AS creator_dispatch_process_incarnation + FROM tasks t + LEFT JOIN dispatch_contexts creator ON creator.rowid = ( + SELECT candidate.rowid + FROM dispatch_contexts candidate + WHERE candidate.assignee_handle = t.created_by_terminal_handle + AND candidate.run_id = ? + AND candidate.status IN ('pending', 'dispatched') + ORDER BY candidate.rowid DESC + LIMIT 1 + ) + WHERE t.id = ?` + ) + .get(dispatchRunId, id) as TaskRuntimeLineageRow | undefined } listTasks(filter?: { status?: TaskStatus; ready?: boolean; runId?: string }): TaskRow[] { @@ -6088,7 +6158,8 @@ export class OrchestrationDb { assigneeHandle: string, // Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue. assigneePaneKey?: string, - launchTokenHash?: string + launchTokenHash?: string, + processIncarnation?: string ): DispatchContextRow { const task = this.getTask(taskId) if (!task) { @@ -6118,8 +6189,9 @@ export class OrchestrationDb { .prepare( `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, - assignee_handle, assignee_pane_key, status, failure_count, dispatched_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, datetime('now'))` + assignee_handle, assignee_pane_key, process_incarnation, + status, failure_count, dispatched_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, datetime('now'))` ) .run( id, @@ -6129,6 +6201,7 @@ export class OrchestrationDb { launchTokenHash ?? null, assigneeHandle, assigneePaneKey ?? null, + processIncarnation ?? null, priorFailures ) this.hasAnyDispatchContextsCache = true @@ -6300,9 +6373,12 @@ export class OrchestrationDb { const actives = this.db .prepare( - "SELECT * FROM dispatch_contexts WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched')" + `SELECT * FROM dispatch_contexts + WHERE assignee_pane_key IS NOT NULL + AND status IN ('pending', 'dispatched') + AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ?` ) - .all() as DispatchContextRow[] + .all(paneKeyMatchSuffix(assigneePaneKey)) as DispatchContextRow[] for (const row of actives) { if (row.assignee_pane_key && isEquivalentPaneKey(row.assignee_pane_key, assigneePaneKey)) { diff --git a/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts b/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts new file mode 100644 index 00000000000..d13344004c3 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' + +const CREATOR_PANE = 'tab-creator:11111111-1111-4111-8111-111111111111' +const CREATOR_PROCESS = 'pty-creator:incarnation-a' + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} + +describe('creator authority lookup performance', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + it('uses bounded creator-handle and pane-leaf indexes', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + const taskPlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + SELECT t.*, creator.id + FROM tasks t + LEFT JOIN dispatch_contexts creator ON creator.rowid = ( + SELECT candidate.rowid + FROM dispatch_contexts candidate + WHERE candidate.assignee_handle = t.created_by_terminal_handle + AND candidate.run_id = ? + AND candidate.status IN ('pending', 'dispatched') + ORDER BY candidate.rowid DESC LIMIT 1 + ) + WHERE t.id = ?` + ) + .all('run-owner', 'task-worker') as { detail: string }[] + const panePlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + SELECT * FROM dispatch_contexts + WHERE assignee_pane_key IS NOT NULL + AND status IN ('pending', 'dispatched') + AND substr(assignee_pane_key, instr(assignee_pane_key, ':') + 1) = ?` + ) + .all('11111111-1111-4111-8111-111111111111') as { detail: string }[] + const taskDetails = taskPlan.map((row) => row.detail).join(' | ') + const paneDetails = panePlan.map((row) => row.detail).join(' | ') + + expect(taskDetails).toContain('idx_dispatch_active_assignee_handle') + expect(taskDetails).not.toMatch(/SCAN (?:runs|rebound)/) + expect(paneDetails).toContain('idx_dispatch_assignee_pane_leaf') + }) + + it('keeps 300 Task reads bounded with 50,000 retained Runs', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'owner', + coordinatorHandle: 'term-coordinator', + coordinatorPaneKey: 'tab-coordinator:22222222-2222-4222-8222-222222222222' + }) + const creatorTask = db.createTask({ spec: 'creator', runId: run.id }) + db.createDispatchContext( + creatorTask.id, + 'term-creator', + CREATOR_PANE, + undefined, + CREATOR_PROCESS + ) + const workerTask = db.createTask({ + spec: 'worker', + runId: run.id, + createdByTerminalHandle: 'term-creator', + createdByPaneKey: CREATOR_PANE, + createdByProcessIncarnation: CREATOR_PROCESS, + createdByRunGeneration: run.consumer_generation + }) + sqliteFor(db) + .prepare( + `WITH RECURSIVE run_numbers(value) AS ( + VALUES (1) UNION ALL SELECT value + 1 FROM run_numbers WHERE value < ? + ) + INSERT INTO runs (id, objective, coordinator_handle, consumer_generation, legacy) + SELECT printf('retained_%05d', value), 'retained', printf('term_%05d', value), 1, 0 + FROM run_numbers` + ) + .run(50_000) + + for (let index = 0; index < 10; index += 1) { + db.getTask(workerTask.id, run.id) + } + const startedAt = performance.now() + for (let index = 0; index < 300; index += 1) { + expect(db.getTask(workerTask.id, run.id)?.creator_dispatch_run_id).toBe(run.id) + } + const elapsedMs = performance.now() - startedAt + + expect(elapsedMs).toBeLessThan(200) + }) + + it.each([20_000, 50_000])( + 'keeps active creator lookup bounded with %i retained same-handle Dispatches', + (retainedDispatchCount) => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'owner', + coordinatorHandle: 'term-coordinator', + coordinatorPaneKey: 'tab-coordinator:22222222-2222-4222-8222-222222222222' + }) + sqliteFor(db) + .prepare( + `WITH RECURSIVE dispatch_numbers(value) AS ( + VALUES (1) UNION ALL SELECT value + 1 FROM dispatch_numbers WHERE value < ? + ) + INSERT INTO dispatch_contexts ( + id, run_id, task_id, assignee_handle, status, completed_at + ) + SELECT printf('retained_ctx_%05d', value), ?, printf('retained_task_%05d', value), + 'term-creator', 'completed', datetime('now') + FROM dispatch_numbers` + ) + .run(retainedDispatchCount, run.id) + const creatorTask = db.createTask({ spec: 'creator', runId: run.id }) + const creatorDispatch = db.createDispatchContext( + creatorTask.id, + 'term-creator', + CREATOR_PANE, + undefined, + CREATOR_PROCESS + ) + const workerTask = db.createTask({ + spec: 'worker', + runId: run.id, + createdByTerminalHandle: 'term-creator', + createdByPaneKey: CREATOR_PANE, + createdByProcessIncarnation: CREATOR_PROCESS, + createdByRunGeneration: run.consumer_generation + }) + + for (let index = 0; index < 10; index += 1) { + db.getTask(workerTask.id, run.id) + db.getActiveDispatchForTerminal('term-creator') + } + const startedAt = performance.now() + for (let index = 0; index < 300; index += 1) { + expect(db.getTask(workerTask.id, run.id)?.creator_dispatch_id).toBe(creatorDispatch.id) + expect(db.getActiveDispatchForTerminal('term-creator')?.id).toBe(creatorDispatch.id) + } + const elapsedMs = performance.now() - startedAt + + const competingTask = db.createTask({ spec: 'competing creator', runId: run.id }) + expect(() => db!.createDispatchContext(competingTask.id, 'term-creator')).toThrow( + `Terminal term-creator already has an active dispatch (${creatorDispatch.id}` + ) + expect(elapsedMs).toBeLessThan(200) + } + ) +}) diff --git a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts index 0b78eaebdd6..8cb0547ee27 100644 --- a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts +++ b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts @@ -218,7 +218,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { } }) - it('migrates a populated v21 database idempotently', () => { + it('migrates a populated upstream v23 database idempotently', () => { tempDir = mkdtempSync(join(tmpdir(), 'orca-dispatch-index-migration-')) const dbPath = join(tempDir, 'orchestration.db') db = new OrchestrationDb(dbPath) @@ -228,14 +228,25 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = undefined const oldDb = new Database(dbPath) - oldDb.exec('DROP INDEX IF EXISTS idx_dispatch_assignee_handle') - oldDb.pragma('user_version = 21') + oldDb.exec(` + DROP INDEX IF EXISTS idx_dispatch_active_assignee_handle; + DROP INDEX IF EXISTS idx_dispatch_assignee_pane_leaf; + ALTER TABLE tasks DROP COLUMN created_by_pane_key; + ALTER TABLE tasks DROP COLUMN created_by_process_incarnation; + ALTER TABLE tasks DROP COLUMN created_by_run_generation; + `) + oldDb.pragma('user_version = 23') oldDb.close() db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(23) + expect(sqlite.pragma('user_version', { simple: true })).toBe(25) expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' }) + expect(db.getTask(task.id)).toMatchObject({ + created_by_pane_key: null, + created_by_process_incarnation: null, + created_by_run_generation: null + }) expect( sqlite .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?") @@ -249,12 +260,75 @@ describe('OrchestrationDb dispatch assignee index migration', () => { ) .all('term_worker') as { detail: string }[] expect(plan.map((row) => row.detail).join('\n')).toContain( - 'USING INDEX idx_dispatch_assignee_handle' + 'USING INDEX idx_dispatch_active_assignee_handle' ) + expect( + sqlite + .prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?") + .get('idx_dispatch_active_assignee_handle') + ).toMatchObject({ + sql: expect.stringContaining("status IN ('pending', 'dispatched')") + }) + expect( + sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?") + .get('idx_dispatch_assignee_pane_leaf') + ).toBeDefined() db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(23) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) expect(db.getDispatchContextById(dispatch.id)).toBeDefined() }) + + it('adds the active-handle index to a populated v24 database idempotently', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-active-dispatch-index-migration-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + const run = db.createRun({ + objective: 'retained v24 authority', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ + spec: 'indexed lookup', + runId: run.id, + createdByTerminalHandle: 'term_creator', + createdByPaneKey: 'tab_creator:leaf_creator', + createdByProcessIncarnation: 'pty_creator:incarnation-a', + createdByRunGeneration: run.consumer_generation + }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + db.close() + db = undefined + + const oldDb = new Database(dbPath) + oldDb.exec('DROP INDEX IF EXISTS idx_dispatch_active_assignee_handle') + oldDb.pragma('user_version = 24') + oldDb.close() + + db = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + expect(sqlite.pragma('user_version', { simple: true })).toBe(25) + expect(db.getTask(task.id)).toMatchObject({ + created_by_pane_key: 'tab_creator:leaf_creator', + created_by_process_incarnation: 'pty_creator:incarnation-a', + created_by_run_generation: 1 + }) + expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ + assignee_handle: 'term_worker' + }) + expect( + sqlite + .prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?") + .get('idx_dispatch_active_assignee_handle') + ).toMatchObject({ + sql: expect.stringContaining('assignee_handle IS NOT NULL') + }) + + db.close() + db = new OrchestrationDb(dbPath) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) + expect(db.getTask(task.id)?.created_by_process_incarnation).toBe('pty_creator:incarnation-a') + }) }) diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index 76b3d536fe8..e399b13ce49 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -246,6 +246,9 @@ export type TaskRow = { run_id: string parent_id: string | null created_by_terminal_handle: string | null + created_by_pane_key: string | null + created_by_process_incarnation: string | null + created_by_run_generation: number | null task_title: string | null display_name: string | null spec: string diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index e2aaf31b6eb..8ba154c48f7 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1763,17 +1763,27 @@ describe('orchestration RPC methods', () => { expect(result.task.status).toBe('pending') }) - it('records the caller terminal handle when creating a task', async () => { + it('records the caller pane, process, and Run generation when creating a task', async () => { setup() vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => handle === 'term_creator' ? coordinatorPaneKey : null ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockReturnValue({ + terminalHandle: 'term_creator', + paneKey: coordinatorPaneKey, + processIncarnation: 'pty-creator:incarnation-a' + } as never) const result = (await call('orchestration.taskCreate', { spec: 'spawn related workspace', callerTerminalHandle: 'term_creator' })) as { task: { id: string } } - expect(db.getTask(result.task.id)?.created_by_terminal_handle).toBe('term_creator') + expect(db.getTask(result.task.id)).toMatchObject({ + created_by_terminal_handle: 'term_creator', + created_by_pane_key: coordinatorPaneKey, + created_by_process_incarnation: 'pty-creator:incarnation-a', + created_by_run_generation: 1 + }) }) it('rejects invalid deps JSON', async () => { @@ -1898,6 +1908,16 @@ describe('orchestration RPC methods', () => { vi.mocked(runtime.getTerminalPaneKey).mockImplementation((candidate) => candidate === handle ? `tab_worker:${handle}` : coordinatorPaneKey ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((candidate) => + candidate === handle + ? ({ + terminalHandle: handle, + paneKey: `tab_worker:${handle}`, + processIncarnation: `runtime_test:${handle}:1`, + launchTokenHash: null + } as never) + : null + ) } it('dispatches a task to a terminal', async () => { @@ -1929,7 +1949,7 @@ describe('orchestration RPC methods', () => { expect(db.getDispatchContextById(result.dispatch.id)?.assignee_pane_key).toBe('tab_w:leaf_w') }) - it('commits the target process launch token on a manual dispatch', async () => { + it('commits authenticated process authority on a manual dispatch', async () => { setup() vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockReturnValue({ runtimeId: runtime.getRuntimeId(), @@ -1948,9 +1968,24 @@ describe('orchestration RPC methods', () => { to: 'term_a' })) as { dispatch: { id: string } } - expect(db.getDispatchContextById(result.dispatch.id)?.launch_token_hash).toBe( - 'launch-token-hash' - ) + expect(db.getDispatchContextById(result.dispatch.id)).toMatchObject({ + assignee_pane_key: 'tab_w:leaf_w', + process_incarnation: 'runtime_test:term_a:1', + launch_token_hash: 'launch-token-hash' + }) + }) + + it('does not infer manual process authority from an unauthenticated handle', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + + const result = (await call('orchestration.dispatch', { + task: task.id, + to: 'term_a' + })) as { dispatch: { id: string } } + + expect(runtime.getTerminalProcessIncarnation('term_a')).toBe('runtime_test:term_a:1') + expect(db.getDispatchContextById(result.dispatch.id)?.process_incarnation).toBeNull() }) it('rejects dispatch for a pending task', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 34ea1f36860..0bdb24543a7 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1100,6 +1100,16 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ throw new Error('Invalid --deps: must be a JSON array of task IDs') } } + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const creatorAuthority = params.callerTerminalHandle + ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) + : null const task = db.createTask({ spec: params.spec, taskTitle: params.taskTitle, @@ -1107,13 +1117,14 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ deps, parentId: params.parent, createdByTerminalHandle: params.callerTerminalHandle, - runId: resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }).id + ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation + ? { + createdByPaneKey: creatorAuthority.paneKey, + createdByProcessIncarnation: creatorAuthority.processIncarnation, + createdByRunGeneration: run.consumer_generation + } + : {}), + runId: run.id }) return { task } } @@ -1256,9 +1267,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const assigneePaneKey = dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined const processIncarnation = - dispatchAuthority?.processIncarnation ?? - runtime.getTerminalProcessIncarnation(to) ?? - undefined + dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation + ? dispatchAuthority.processIncarnation + : undefined if (params.inject && (!assigneePaneKey || !processIncarnation)) { throw new OrchestrationError( 'stable_pane_required', @@ -1271,7 +1282,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ params.task, to, assigneePaneKey, - dispatchAuthority?.launchTokenHash ?? undefined + dispatchAuthority?.launchTokenHash ?? undefined, + processIncarnation ) const dispatchCapability = params.inject ? db.mintDispatchCapability({ diff --git a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts index be5628df496..468f261b725 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts @@ -628,6 +628,16 @@ describe('legacy coordinator takeover races', () => { handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === targetHandle ? targetPane : null ) vi.spyOn(harness.runtime, 'getTerminalProcessIncarnation').mockReturnValue('current-process') + vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === targetHandle + ? ({ + terminalHandle: targetHandle, + paneKey: targetPane, + processIncarnation: 'current-process', + launchTokenHash: null + } as never) + : null + ) let resolveDetection: ((detected: boolean) => void) | undefined let signalDetectionStarted: (() => void) | undefined const detectionStarted = new Promise((resolve) => { diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 70c334eae1a..b6b2976e26b 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -265,6 +265,42 @@ describe('agent status runtime orchestration metadata', () => { }) }) + it('clears stale lineage when the authoritative runtime snapshot loses its Run binding', () => { + vi.useFakeTimers() + const store = createTestStore() + const childPaneKey = 'tab-child:11111111-1111-4111-8111-111111111111' + + store.getState().setAgentStatus(childPaneKey, { + state: 'working', + prompt: 'child agent', + agentType: 'codex', + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'dispatched', + parentTerminalHandle: 'term-old-coordinator', + parentPaneKey: 'tab-parent:22222222-2222-4222-8222-222222222222', + coordinatorHandle: 'term-old-coordinator', + orchestrationRunId: 'run-1' + } + }) + store.getState().setRuntimeAgentOrchestrationByPaneKey({ + [childPaneKey]: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'dispatched', + orchestrationRunId: 'run-1' + } + }) + + expect(store.getState().agentStatusByPaneKey[childPaneKey].orchestration).toEqual({ + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'dispatched', + orchestrationRunId: 'run-1' + }) + }) + it('updates runtime status for the same dispatch', () => { vi.useFakeTimers() const store = createTestStore() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index d8c87362edc..921cc58d02a 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1125,6 +1125,9 @@ function mergeCurrentOrchestrationContext( if (!sameDispatch) { return current } + if (current.dispatchStatus !== undefined) { + return orchestrationContextsEqual(existing, current) ? existing : current + } const merged = { ...existing, ...current } return orchestrationContextsEqual(existing, merged) ? existing : merged } From 9ba293cb74c05e11e87fb934d3907be553dcb0a3 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 21:37:29 -0700 Subject: [PATCH 028/101] fix(mobile): keep relay runtime recovery alive without direct connectivity (#12374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): keep relay runtime recovery alive without direct connectivity A phone paired over the relay whose direct LAN endpoint is unreachable (e.g. a Tailscale IP with Tailscale off) could lose the runtime channel permanently: the reconnect controller's recovery gates parked with no timer and no logs, the supervisor snapshotted relay credentials once at start (dying silently if the read failed and dialing stale tokens after rotation), and the only path that cleared a rejected-credential gate required a working direct connection. Field symptom: home card shows "Connected - Orca Relay" (or "Can't connect - check Tailscale") while the host page sits at zero worktrees forever. - gates (fresh-credential, external-signal) now arm a slow 60s reprobe instead of parking; each gated attempt re-reads the durable credential bundle and adopts it when its version is fresher than the rejected one - supervisor start no longer dies for the process lifetime when the initial Keychain read fails or the bundle is expired - every recovery decision now reaches logcat and the in-app connection log ([relay] lines); previously the whole relay dial path was silent - direct-return probing extracted to mobile-direct-return-probe.ts, credential selection to mobile-relay-credential-selection.ts Regression suite mirrors the field failure (rejected outer credential, unreadable bundle at start, expired bundle, E2EE rejection without a UI nudge) plus real-rpc-client failover integration tests; the four deterministic scenarios fail on the previous code. * fix(mobile): adopt durable relay credentials by outcome, not version Adversarial review caught two blockers in the version-comparison rule: renewals extend expiresAt without bumping current.version, and a re-pair restarts the version counter — both left the durable bundle unadopted and reproduced the original outage. Selection now adopts the disk bundle exactly when it yields a dialable (unexpired, non-rejected) credential while memory does not, which also keeps revoked versions unresurrectable. Also from review: the gate reprobe cadence now escalates 60s -> 15min ceiling with 0.75-1.25x jitter (no fleet phase-alignment, no permanent one-minute beacon); clearing a gate drops its timer, pending tick, and cadence so an orphaned reprobe cannot swallow the next fast backoff; the reprobe tick token is only minted while its gate still holds; and a merely missing/expired bundle uses a plain cooldown instead of the fresh-credential gate so it cannot force rotations on direct reconnects. New regression tests (all red on the previous code): renewal without a version bump, re-pair with a restarted counter, orphaned-timer backoff swallowing, escalating gated cadence, and background/foreground recovery after an E2EE rejection. * fix(mobile): reset gated relay cadence on app resume Review round 2: an escalated fresh-credential gate kept its cadence across background/foreground, so reopening the app could wait out a 15-minute tick (measured 11.25min to first attempt after a 2h background) — indistinguishable from the outage itself. A resume now resets the streak even when it cannot lift the credential gate, and a successful direct connection does the same in resetForDirectConnection. Also: the streak now advances once per fired tick instead of once per armed-delay computation (three arms per cycle escalated 60s -> ceiling in ~7 minutes instead of the documented eight steps); delay computation is a pure read. * fix(mobile): rotate relay sessions on resume expiry, not attach deadline Live phone verification of the failover fix exposed a second defect the old latch had been masking: the relay-hello's leaseExpiresAt is the cell's attach-reservation deadline (now + 10s for resumes, credential-store.ts:213 server-side), but the supervisor scheduled proactive rotation from it with a 30s margin clamped to 1s — so every relay runtime session force-replaced itself ~1s after connecting (measured every ~2.5s on device, 253 dials per 5 simulated minutes in the red test). Any RPC slower than the cycle could never complete, which is the "Worktree list unavailable" symptom. The session now captures resumeExpiresAt from the hello (updated by the resume confirmation) and rotation keys off it. Test fakes previously used a 120s lease, which is why no suite ever reproduced the loop; they now mirror the production 10s attach deadline, and a churn regression holds one session across 5 minutes with direct unreachable. * fix(mobile): clamp lease rotation delay on both ends Adversarial review of the resume-expiry rotation fix caught an int32 setTimeout overflow: production resumeTtlMs is 30 days, and 30d - 30s = 2,591,970,000ms exceeds INT32_MAX, so Node (and vitest's fake timers) clamp the timer to 1ms — 3001 relay dials and credential writes in 3 simulated seconds, ~2500x worse than the churn being fixed. The delay is now clamped to [60s, 6h]: the ceiling makes overflow unreachable regardless of server TTL (a harmless re-resume every 6h on long sessions), and the floor bounds any bad deadline to one forced rotation per minute instead of a sub-second loop — which also disarms the Math.max(1000, ...) landmine for return-unchanged-grace resumes whose stored expiry can be arbitrarily near. Also from review: getLeaseExpiresAt is renamed getAttachDeadlineAt (it had zero production callers left; the plausible name is how the churn bug happened), the expired-vs-missing bundle cases now log distinct strings, and both test fakes use production constants (10s attach deadline, 30-day resume TTL) — fictional fake values hid all three defects in this subsystem. The four forced-rotation lease tests are retimed to the 60s floor with direct pinned unreachable so return probes cannot race their windows. * style(mobile): merge duplicate imports in relay failover test * style(mobile): use T[] array syntax in credential selection --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../transport/mobile-direct-return-probe.ts | 83 ++++ .../transport/mobile-endpoint-lifecycle.ts | 1 + .../mobile-endpoint-supervisor-contract.ts | 3 +- .../mobile-endpoint-supervisor.test.ts | 26 +- .../transport/mobile-endpoint-supervisor.ts | 160 +++--- .../mobile-relay-credential-selection.ts | 42 ++ .../mobile-relay-lease-rotation-timer.ts | 16 +- .../mobile-relay-reconnect-controller.test.ts | 98 +++- .../mobile-relay-reconnect-controller.ts | 111 ++++- .../transport/mobile-relay-recovery-log.ts | 22 + .../mobile-relay-rpc-session.test.ts | 2 +- .../src/transport/mobile-relay-rpc-session.ts | 15 +- .../mobile-relay-runtime-failover.test.ts | 454 ++++++++++++++++++ 13 files changed, 917 insertions(+), 116 deletions(-) create mode 100644 mobile/src/transport/mobile-direct-return-probe.ts create mode 100644 mobile/src/transport/mobile-relay-credential-selection.ts create mode 100644 mobile/src/transport/mobile-relay-recovery-log.ts create mode 100644 mobile/src/transport/mobile-relay-runtime-failover.test.ts diff --git a/mobile/src/transport/mobile-direct-return-probe.ts b/mobile/src/transport/mobile-direct-return-probe.ts new file mode 100644 index 00000000000..dfb0572aa38 --- /dev/null +++ b/mobile/src/transport/mobile-direct-return-probe.ts @@ -0,0 +1,83 @@ +import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' +import type { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' +import type { RpcClient } from './rpc-client' +import type { HostProfile } from './types' +import type { MobileConnectionPath } from './stable-logical-rpc-client' + +const DIRECT_PROBE_INTERVAL_MS = 15_000 + +// While the runtime channel rides the relay, periodically probe the direct +// endpoint and migrate back once hysteresis proves it stable. +export class DirectReturnProbe { + private timer: ReturnType | null = null + + constructor( + private readonly deps: { + now: () => number + setTimer: typeof setTimeout + clearTimer: typeof clearTimeout + openDirect: (endpoint: string) => RpcClient + }, + private readonly hooks: { + hysteresis: MobileEndpointHysteresis + host: () => HostProfile + canSchedule: () => boolean + canAttempt: () => boolean + beginOperation: () => void + migrate: (client: RpcClient, path: MobileConnectionPath) => Promise + onDirectMigrated: () => Promise + afterProbe: () => void + } + ) {} + + schedule(delayMs = DIRECT_PROBE_INTERVAL_MS): void { + if (!this.hooks.canSchedule() || this.timer) { + return + } + this.timer = this.deps.setTimer(() => { + this.timer = null + void this.probe() + }, delayMs) + } + + clear(): void { + if (this.timer) { + this.deps.clearTimer(this.timer) + this.timer = null + } + } + + private async probe(): Promise { + if (!this.hooks.canAttempt() || !this.hooks.hysteresis.canProbe(this.deps.now())) { + this.schedule() + return + } + this.hooks.beginOperation() + let successful: Awaited> = null + try { + successful = await openAuthenticatedDirectEndpoint( + this.hooks.host(), + this.deps.openDirect, + 12_000 + ) + if (!successful) { + this.hooks.hysteresis.recordDirectFailure(this.deps.now()) + return + } + if (!this.hooks.hysteresis.recordDirectSuccess(this.deps.now())) { + successful.client.close() + return + } + await this.hooks.migrate(successful.client, successful.path) + successful = null + this.hooks.hysteresis.recordMigration(this.deps.now()) + await this.hooks.onDirectMigrated() + } finally { + successful?.client.close() + // Why: a relay drop or backoff timer can arrive while the probe owns the + // operation mutex; afterProbe releases it and replays deferred recovery. + this.hooks.afterProbe() + this.schedule() + } + } +} diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts index 57881022a56..d41898152c3 100644 --- a/mobile/src/transport/mobile-endpoint-lifecycle.ts +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -90,6 +90,7 @@ function createSupervisor( readBundle: readMobileRelayCredentialBundle, writeBundle: writeMobileRelayCredentialBundle, saveHost, + onLog, now: Date.now, randomBytes: ExpoCrypto.getRandomBytes, setTimer: setTimeout, diff --git a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts index d26e0706f70..0098de6e079 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts @@ -3,7 +3,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' import type { RpcClient } from './rpc-client' -import type { HostProfile } from './types' +import type { ConnectionLogSink, HostProfile } from './types' export type MobileEndpointSupervisorDependencies = { openDirect: (endpoint: string) => RpcClient @@ -20,4 +20,5 @@ export type MobileEndpointSupervisorDependencies = { randomBytes: (length: number) => Uint8Array setTimer: typeof setTimeout clearTimer: typeof clearTimeout + onLog?: ConnectionLogSink } diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index 3ec05f5ef1e..e6d0ff8bb32 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -52,18 +52,21 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { constructor( state: ConnectionState, private readonly failure: Error | null = null, - private readonly lease = Date.now() + 120_000 + private readonly resumeExpiry = Date.now() + 30 * 24 * 3_600_000 ) { super(state) } - getLeaseExpiresAt = () => this.lease + // Why: production-realistic defaults — fictional fake values hid three + // live defects in this subsystem (latch, churn, int32 timer overflow). + getAttachDeadlineAt = () => Date.now() + 10_000 + getResumeExpiresAt = () => this.resumeExpiry getResumeConfirmation = () => ({ v: 1 as const, reqId: 'confirm-1', currentVersion: 2, acceptedAs: 'current' as const, renewed: true, - resumeExpiresAt: Date.now() + 300_000 + resumeExpiresAt: this.resumeExpiry }) getFailure = () => this.failure } @@ -716,6 +719,7 @@ describe('mobile endpoint supervisor', () => { .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408))) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), resolveRelay: vi.fn(() => resolvePending) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) @@ -744,12 +748,13 @@ describe('mobile endpoint supervisor', () => { .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408))) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), resolveRelay: vi.fn(() => resolvePending) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) await vi.waitFor(() => expect(deps.resolveRelay).toHaveBeenCalledOnce()) supervisor.setForeground(false) finishResolve?.(relay) @@ -785,11 +790,14 @@ describe('mobile endpoint supervisor', () => { .fn() .mockReturnValueOnce(new FakeRelaySession('connected', null, Date.now() + 31_000)) .mockImplementation(() => new FakeRelaySession('disconnected', new RelayOuterError(4404))) - const deps = dependencies({ openRelay }) + const deps = dependencies({ + openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')) + }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) expect(openRelay).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(5000) @@ -808,12 +816,13 @@ describe('mobile endpoint supervisor', () => { .mockImplementation(() => new FakeRelaySession('connected')) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), randomBytes: () => new Uint8Array([128, 0]) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) expect(openRelay).toHaveBeenCalledTimes(2) // The old relay can outlive its rejected lease replacement, then close separately. @@ -833,12 +842,13 @@ describe('mobile endpoint supervisor', () => { .mockImplementation(() => new FakeRelaySession('connected')) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), randomBytes: () => new Uint8Array([128, 0]) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) expect(openRelay).toHaveBeenCalledTimes(2) supervisor.setForeground(true) diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index c8b3cdbcfb1..77ffbbde3db 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -1,5 +1,5 @@ -import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract' +import { DirectReturnProbe } from './mobile-direct-return-probe' import { RelayReconnectController } from './mobile-relay-reconnect-controller' import { RelayLeaseRotationTimer } from './mobile-relay-lease-rotation-timer' import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' @@ -9,6 +9,8 @@ import { persistRelayHost, toError } from './mobile-endpoint-supervisor-support' +import { selectDialableRelayCredentials } from './mobile-relay-credential-selection' +import { createRelayRecoveryLog, type RelayRecoveryLog } from './mobile-relay-recovery-log' import { applyResumeConfirmation, mobileRelayCredentialNeedsRotation, @@ -20,7 +22,6 @@ import type { HostProfile } from './types' export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract' -const DIRECT_PROBE_INTERVAL_MS = 15_000 const DIRECT_OBSERVATION_MS = 30_000 const MINIMUM_DWELL_MS = 60_000 const FAILURE_COOLDOWN_MS = 60_000 @@ -32,11 +33,12 @@ export class MobileEndpointSupervisor { private operationInFlight = false private credentialRotationInFlight = false private relayRotationPending = false - private probeTimer: ReturnType | null = null private unsubscribeState: (() => void) | null = null private readonly hysteresis: MobileEndpointHysteresis private readonly relayReconnect: RelayReconnectController private readonly leaseRotation: RelayLeaseRotationTimer + private readonly logRelay: RelayRecoveryLog + private readonly directProbe: DirectReturnProbe constructor( private readonly logical: StableLogicalRpcClient, @@ -49,24 +51,52 @@ export class MobileEndpointSupervisor { failureCooldownMs: FAILURE_COOLDOWN_MS, minimumDwellMs: MINIMUM_DWELL_MS }) + this.logRelay = createRelayRecoveryLog(dependencies.now, dependencies.onLog) this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this)) this.leaseRotation = new RelayLeaseRotationTimer(dependencies, () => { this.relayRotationPending = true void this.recoverRelay(true) }) + this.directProbe = new DirectReturnProbe(dependencies, { + hysteresis: this.hysteresis, + host: () => this.host, + canSchedule: () => + !this.stopped && this.foreground && this.logical.getActivePath() === 'relay', + canAttempt: () => !this.stopped && this.foreground && !this.operationInFlight, + beginOperation: () => { + this.operationInFlight = true + }, + migrate: (client, path) => this.logical.migrateTo(client, path), + onDirectMigrated: async () => { + this.leaseRotation.clear() + this.relayRotationPending = false + await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) + }, + afterProbe: () => { + this.operationInFlight = false + if (this.relayRotationPending || this.logical.getState() !== 'connected') { + void this.recoverRelay(this.relayRotationPending) + } + } + }) } async start(): Promise { this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null) - if (this.stopped || !this.bundle || !this.host.relay) { + if (this.stopped || !this.host.relay) { return } + if (!this.bundle) { + // Why: a Keychain race at open must not kill relay recovery for the whole + // process lifetime; each recovery attempt re-reads the durable bundle. + this.logRelay('credential bundle unavailable at start; recovery will re-read') + } this.unsubscribeState = this.logical.onStateChange((state) => { if (state === 'connected') { if (this.logical.getActivePath() !== 'relay') { void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) } - this.scheduleDirectProbe() + this.directProbe.schedule() } else { // Why: the direct client enters reconnecting after its first failed // dial and may never publish disconnected while its retry loop lives. @@ -78,7 +108,7 @@ export class MobileEndpointSupervisor { // are still loading, before the supervisor subscribes to state changes. await this.recoverRelay() } else { - this.scheduleDirectProbe() + this.directProbe.schedule() } } @@ -87,11 +117,11 @@ export class MobileEndpointSupervisor { this.foreground = foreground if (foreground) { this.relayReconnect.handleForeground(this.logical, wasForeground) - this.scheduleDirectProbe(0) + this.directProbe.schedule(0) } else { // Why: background phones must not hold billed relay data splices. this.relayReconnect.suspendActiveRelay(this.logical) - this.clearDirectProbeTimer() + this.directProbe.clear() this.relayReconnect.clear() this.leaseRotation.clear() } @@ -101,7 +131,7 @@ export class MobileEndpointSupervisor { this.stopped = true this.unsubscribeState?.() this.unsubscribeState = null - this.clearDirectProbeTimer() + this.directProbe.clear() this.relayReconnect.clear() this.leaseRotation.clear() } @@ -112,7 +142,6 @@ export class MobileEndpointSupervisor { this.stopped || !this.foreground || this.operationInFlight || - !this.bundle || !this.host.relay || (!forceReplacement && !this.relayReconnect.needsRecovery(this.logical.getState())) ) { @@ -121,23 +150,42 @@ export class MobileEndpointSupervisor { // Why: revival and lease timers can overlap resume failures; one shared cooldown // prevents PEER_DROPPED/LIMIT_EXCEEDED reconnect churn. if (this.relayReconnect.shouldDefer()) { + this.logRelay('recovery deferred by cooldown or gate') return } this.operationInFlight = true let lastError: Error | null = null let retryAfterOperation = false try { - const credentials = this.relayReconnect.eligibleCredentials( - this.bundle.current, - this.bundle.grace - ) - for (const credential of credentials) { + const selection = await selectDialableRelayCredentials({ + bundle: this.bundle, + controller: this.relayReconnect, + readBundle: () => this.dependencies.readBundle(this.host.id), + onAdoptedFresherBundle: () => this.logRelay('adopted fresher durable credential bundle') + }) + this.bundle = selection.bundle + if (selection.credentials.length === 0) { + // Why: "expired" vs "missing" separates a sleep-past-expiry phone + // (needs re-pair or LAN) from a Keychain failure in field reports. + this.logRelay( + selection.bundle + ? 'relay credential expired or rejected; slow reprobe armed' + : 'no relay credential bundle; slow reprobe armed' + ) + this.relayReconnect.armCredentialReprobe() + return + } + for (const credential of selection.credentials) { const result = await this.tryRelayCredential(credential) if (result.ok) { retryAfterOperation = this.logical.getState() !== 'connected' return } lastError = result.error + this.logRelay( + 'relay dial failed', + `${result.error.name}: ${String(result.error.message).slice(0, 80)}` + ) if (this.relayReconnect.shouldTryGraceAfterRelayFailure(result.error)) { // Why: a rejected version stays invalid; retry only the grace credential. this.relayReconnect.recordRejectedCredential(credential.version) @@ -145,12 +193,10 @@ export class MobileEndpointSupervisor { break } } - if (credentials.length > 0) { - // Why: cleanup may happen while a relay dial is awaiting the network; - // record its outcome without recreating a foreground retry timer. - const scheduleRetry = !forceReplacement && this.foreground && !this.stopped - this.relayReconnect.registerFailure(lastError, scheduleRetry) - } + // Why: cleanup may happen while a relay dial is awaiting the network; + // record its outcome without recreating a foreground retry timer. + const scheduleRetry = !forceReplacement && this.foreground && !this.stopped + this.relayReconnect.registerFailure(lastError, scheduleRetry) } finally { this.operationInFlight = false if (forceReplacement && this.relayRotationPending && !this.stopped && this.foreground) { @@ -207,6 +253,7 @@ export class MobileEndpointSupervisor { } this.relayRotationPending = false this.hysteresis.recordMigration(this.dependencies.now()) + this.logRelay('runtime channel migrated to relay') const confirmation = session.getResumeConfirmation() if (confirmation) { this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation) @@ -215,71 +262,21 @@ export class MobileEndpointSupervisor { await this.dependencies.writeBundle(this.bundle).catch(() => {}) } // Why: async persistence can finish after stop/background; never recreate a stale timer. + // Why: rotate against the resume credential's expiry, never the hello's + // leaseExpiresAt — that field is the cell's ~10s attach-reservation + // deadline, and using it forced a session replacement every second. this.leaseRotation.scheduleFromLease( - this.stopped || !this.foreground ? null : session.getLeaseExpiresAt() + this.stopped || !this.foreground + ? null + : (confirmation?.resumeExpiresAt ?? session.getResumeExpiresAt()) ) - this.scheduleDirectProbe() + this.directProbe.schedule() return { ok: true } } catch (error) { return { ok: false, error: session.getFailure() ?? toError(error) } } } - private scheduleDirectProbe(delayMs = DIRECT_PROBE_INTERVAL_MS): void { - if ( - this.stopped || - !this.foreground || - this.logical.getActivePath() !== 'relay' || - this.probeTimer - ) { - return - } - this.probeTimer = this.dependencies.setTimer(() => { - this.probeTimer = null - void this.probeDirect() - }, delayMs) - } - - private async probeDirect(): Promise { - if ( - this.stopped || - !this.foreground || - this.operationInFlight || - !this.hysteresis.canProbe(this.dependencies.now()) - ) { - this.scheduleDirectProbe() - return - } - this.operationInFlight = true - let successful: Awaited> = null - try { - const openDirect = this.dependencies.openDirect - successful = await openAuthenticatedDirectEndpoint(this.host, openDirect, 12_000) - if (!successful) { - this.hysteresis.recordDirectFailure(this.dependencies.now()) - return - } - if (!this.hysteresis.recordDirectSuccess(this.dependencies.now())) { - successful.client.close() - return - } - await this.logical.migrateTo(successful.client, successful.path) - successful = null - this.hysteresis.recordMigration(this.dependencies.now()) - this.leaseRotation.clear() - this.relayRotationPending = false - await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) - } finally { - successful?.client.close() - this.operationInFlight = false - // Why: a relay drop or backoff timer can arrive while the direct probe owns the mutex. - if (this.relayRotationPending || this.logical.getState() !== 'connected') { - void this.recoverRelay(this.relayRotationPending) - } - this.scheduleDirectProbe() - } - } - private async rotateCredentialIfNeeded(force = false): Promise { if ( this.stopped || @@ -321,11 +318,4 @@ export class MobileEndpointSupervisor { } } } - - private clearDirectProbeTimer(): void { - if (this.probeTimer) { - this.dependencies.clearTimer(this.probeTimer) - this.probeTimer = null - } - } } diff --git a/mobile/src/transport/mobile-relay-credential-selection.ts b/mobile/src/transport/mobile-relay-credential-selection.ts new file mode 100644 index 00000000000..dcaca2080dd --- /dev/null +++ b/mobile/src/transport/mobile-relay-credential-selection.ts @@ -0,0 +1,42 @@ +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { RelayReconnectController } from './mobile-relay-reconnect-controller' + +// Why: pairing recovery, re-pairing, or a raced rotation can land a fresh +// durable bundle after the supervisor snapshotted its copy; a gated retry must +// see it instead of dialing (or refusing to dial) on stale state forever. +// +// Adoption is decided by OUTCOME, not by version: renewals extend expiresAt +// without bumping current.version, and a re-pair restarts the version counter, +// so version comparison cannot tell fresh from stale. The durable bundle is +// adopted exactly when it yields a dialable (unexpired, non-rejected) +// credential while the in-memory one does not — which also means a revoked +// version can never be resurrected from a stale disk copy. +export async function selectDialableRelayCredentials(args: { + bundle: MobileRelayCredentialBundle | null + controller: RelayReconnectController + readBundle: () => Promise + onAdoptedFresherBundle: () => void +}): Promise<{ + bundle: MobileRelayCredentialBundle | null + credentials: MobileRelayCredentialBundle['current'][] +}> { + const memory = args.bundle + const memoryCredentials = memory + ? args.controller.eligibleCredentials(memory.current, memory.grace) + : [] + if (memoryCredentials.length > 0) { + // Why: in-memory can be newer than disk (a resume confirmation whose + // durable write failed); never let a stale disk copy shadow it. + return { bundle: memory, credentials: memoryCredentials } + } + const disk = await args.readBundle().catch(() => null) + if (disk) { + const diskCredentials = args.controller.eligibleCredentials(disk.current, disk.grace) + if (diskCredentials.length > 0) { + args.controller.acceptFreshCredential(disk.current.version) + args.onAdoptedFresherBundle() + return { bundle: disk, credentials: diskCredentials } + } + } + return { bundle: memory ?? disk, credentials: [] } +} diff --git a/mobile/src/transport/mobile-relay-lease-rotation-timer.ts b/mobile/src/transport/mobile-relay-lease-rotation-timer.ts index bf2a3dd4d1c..19aedfe8be1 100644 --- a/mobile/src/transport/mobile-relay-lease-rotation-timer.ts +++ b/mobile/src/transport/mobile-relay-lease-rotation-timer.ts @@ -2,6 +2,13 @@ // little before the deadline (and retry shortly if a forced rotation didn't land) // so the session never lapses. Owns the single lease/rotation timer slot. const LEASE_ROTATION_MARGIN_MS = 30_000 +// Why: both ends must be clamped. The floor bounds any bad deadline to one +// forced rotation per minute instead of a sub-second loop; the ceiling keeps +// the delay far below setTimeout's 32-bit limit (a 30-day resume TTL minus the +// margin overflows int32 and fires at 1ms), at the cost of a harmless +// re-resume every few hours on long-lived sessions. +const LEASE_ROTATION_MIN_DELAY_MS = 60_000 +const LEASE_ROTATION_MAX_DELAY_MS = 6 * 60 * 60 * 1000 export type RelayLeaseRotationDependencies = { now: () => number @@ -23,9 +30,12 @@ export class RelayLeaseRotationTimer { if (!leaseExpiresAt) { return } - const delay = Math.max( - 1000, - leaseExpiresAt - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS + const delay = Math.min( + LEASE_ROTATION_MAX_DELAY_MS, + Math.max( + LEASE_ROTATION_MIN_DELAY_MS, + leaseExpiresAt - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS + ) ) this.arm(delay) } diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts index d630f0db68b..cc640d4d7e7 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts @@ -6,7 +6,9 @@ import { RelayReconnectController } from './mobile-relay-reconnect-controller' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) -vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length) +})) describe('relay reconnect controller', () => { beforeEach(() => { @@ -47,16 +49,104 @@ describe('relay reconnect controller', () => { expect(onRetry).not.toHaveBeenCalled() }) - it('waits for an external signal after rejected E2EE authentication', () => { + it('reprobes slowly after rejected E2EE authentication instead of parking forever', () => { + // Why: on a relay-only phone a permanent gate is a permanent outage — the + // desktop can commit pairing credentials moments after the first rejection. const onRetry = vi.fn() const reconnect = createController(onRetry) reconnect.registerFailure(new MobileE2EEAuthenticationError()) expect(reconnect.shouldDefer()).toBe(true) - expect(vi.getTimerCount()).toBe(0) - vi.advanceTimersByTime(60_000) expect(onRetry).not.toHaveBeenCalled() + vi.advanceTimersByTime(59_000) + expect(onRetry).not.toHaveBeenCalled() + vi.advanceTimersByTime(1_000) + expect(onRetry).toHaveBeenCalledTimes(1) + // The reprobe tick passes the gate exactly once, then defers again. + expect(reconnect.shouldDefer()).toBe(false) + expect(reconnect.shouldDefer()).toBe(true) + }) + + it('keeps the fresh-credential gate reprobing after each failed gated attempt', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.registerFailure(new RelayOuterError(4401)) + expect(reconnect.shouldDefer()).toBe(true) + + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + expect(reconnect.shouldDefer()).toBe(false) + // The gated attempt fails again with only rejected credentials on hand. + reconnect.registerFailure(new RelayOuterError(4401)) + + // The gated cadence escalates: the second reprobe waits twice as long. + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(2) + }) + + it('resets the gated cadence when the app returns to the foreground', () => { + // Why: reopening the app is the strongest "conditions changed" signal a + // phone produces — it must not wait out an escalated 15-minute tick even + // when it cannot lift the credential gate itself. + const onRetry = vi.fn() + const reconnect = createController(onRetry) + const logical = { getState: () => 'disconnected' } as never + + reconnect.registerFailure(new RelayOuterError(4401)) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + // A failed gated attempt escalates the next tick beyond the base cadence. + reconnect.registerFailure(new RelayOuterError(4401)) + + reconnect.clear() + reconnect.handleForeground(logical, false) + expect(onRetry).toHaveBeenCalledTimes(2) + + expect(reconnect.shouldDefer()).toBe(true) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(3) + }) + + it('does not let an orphaned reprobe timer swallow the next fast backoff', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + const logical = { getState: () => 'disconnected' } as never + + reconnect.registerFailure(new MobileE2EEAuthenticationError()) + // A foreground revival nudge clears the external-signal gate and its timer. + reconnect.handleForeground(logical, true) + expect(onRetry).toHaveBeenCalledTimes(1) + + // A plain transport failure must schedule its own fast retry, not wait + // out a leftover 60s reprobe timer. + reconnect.registerFailure(new RelayOuterError(4429)) + vi.advanceTimersByTime(600) + expect(onRetry).toHaveBeenCalledTimes(2) + }) + + it('lifts the fresh-credential gate when a durable bundle carries a new version', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.recordRejectedCredential(2) + reconnect.armCredentialReprobe() + expect(reconnect.shouldDefer()).toBe(true) + + reconnect.acceptFreshCredential(2) + expect(reconnect.shouldDefer()).toBe(true) + + reconnect.acceptFreshCredential(3) + expect(reconnect.shouldDefer()).toBe(false) + expect( + reconnect.eligibleCredentials( + { token: 'fresh', version: 3, expiresAt: Number.MAX_SAFE_INTEGER }, + null + ) + ).toHaveLength(1) }) it('upgrades host-revival gating to fresh credentials without later downgrading it', () => { diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index 6f586c17499..f2a7cad6b05 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -17,6 +17,12 @@ const RELAY_BACKOFF_CEILING_MS = 30_000 const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000 const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 +// Why: gates must slow recovery down, never end it — a relay-only phone has no +// direct path to refresh credentials, so a timerless gate is a permanent outage. +// The cadence escalates and jitters so a permanently revoked device is not a +// forever one-minute beacon and a fleet-wide gating event cannot phase-align. +const RELAY_GATE_REPROBE_BASE_MS = 60_000 +const RELAY_GATE_REPROBE_CEILING_MS = 15 * 60_000 export type RelayReconnectDependencies = { now: () => number @@ -34,6 +40,8 @@ export class RelayReconnectController { private timer: ReturnType | null = null private activeSession: MobileRelayRpcSession | null = null private recoveryGate: RecoveryGate | null = null + private gateReprobePending = false + private gateReprobeStreak = 0 private readonly rejectedCredentialVersions = new Set() constructor( @@ -43,12 +51,15 @@ export class RelayReconnectController { handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void { if (!wasForeground) { - // Why: an app resume is a fresh signal, unlike repeated network-flap nudges. + // Why: an app resume is a fresh signal, unlike repeated network-flap + // nudges — it resets the gated cadence even when it cannot lift the + // credential gate, so reopening the app never waits out a 15min tick. + this.gateReprobeStreak = 0 if (this.recoveryGate !== 'fresh-credential') { this.reset() } } else if (this.recoveryGate === 'external-signal') { - this.recoveryGate = null + this.liftGate() } if ( wasForeground && @@ -87,8 +98,7 @@ export class RelayReconnectController { this.activeSession = session this.activeRelayConnectedAt = this.dependencies.now() this.nextAttemptAt = 0 - this.recoveryGate = null - this.clearTimer() + this.liftGate() } resetForDirectConnection(): boolean { @@ -97,10 +107,14 @@ export class RelayReconnectController { this.activeSession = null this.activeRelayConnectedAt = null if (needsCredentialRefresh) { - // Why: the rejected credential stays unusable until its replacement is durable. + // Why: the rejected credential stays unusable until its replacement is + // durable. No reprobe timer here — direct is live, rotation over it + // clears the gate, and any later state failure re-arms via shouldDefer. this.consecutiveFailures = 0 this.nextAttemptAt = 0 this.recoveryGate = 'fresh-credential' + this.gateReprobePending = false + this.gateReprobeStreak = 0 this.clearTimer() } else { this.reset() @@ -128,10 +142,36 @@ export class RelayReconnectController { if (eligible.length === 0 && this.rejectedCredentialVersions.size > 0) { this.recoveryGate = 'fresh-credential' this.clearTimer() + this.scheduleGateReprobe() } return eligible } + // For callers that found no dialable credential at all: keep a slow retry + // alive so a later durable write can recover. + armCredentialReprobe(): void { + if (this.rejectedCredentialVersions.size > 0) { + this.recoveryGate = 'fresh-credential' + this.clearTimer() + this.scheduleGateReprobe() + return + } + // Why: a merely missing or expired bundle must not enter the credential + // gate — that gate forces a rotation on the next direct connect. A plain + // cooldown retries the read on the same escalating cadence. + const delay = this.gateReprobeDelayMs() + this.nextAttemptAt = this.dependencies.now() + delay + this.clearTimer() + this.scheduleReprobeTick(delay, false) + } + + // A durable bundle whose current version is not rejected reopens the gate. + acceptFreshCredential(version: number): void { + if (this.recoveryGate === 'fresh-credential' && !this.rejectedCredentialVersions.has(version)) { + this.liftGate() + } + } + recordRejectedCredential(version: number): void { this.rejectedCredentialVersions.add(version) } @@ -154,6 +194,12 @@ export class RelayReconnectController { // re-dial. Arms the self-scheduled retry so recovery still happens on its own. shouldDefer(): boolean { if (this.recoveryGate) { + if (this.gateReprobePending) { + // Why: the slow reprobe tick gets exactly one attempt through the gate. + this.gateReprobePending = false + return false + } + this.scheduleGateReprobe() return true } if (this.dependencies.now() < this.nextAttemptAt) { @@ -173,8 +219,9 @@ export class RelayReconnectController { this.recoveryGate === 'fresh-credential' || (this.recoveryGate === 'external-signal' && recovery?.kind !== 'disable-relay-credential') ) { - // Why: only the gate's external signal can make a known-fatal recovery retryable. + // Why: a failed reprobe stays gated, but the slow cadence must keep going. this.clearTimer() + this.scheduleGateReprobe() return } const now = this.dependencies.now() @@ -192,15 +239,20 @@ export class RelayReconnectController { recovery?.kind === 'retry-after-host-offline' ? this.hostOfflineDelayMs() : this.delayMs() this.nextAttemptAt = now + delay if (error instanceof MobileE2EEAuthenticationError) { - // Why: pairing state cannot change on a timer; polling only wakes the radio. + // Why: an E2EE rejection is usually pairing revocation, but it also fires + // transiently right after pairing while the desktop commits credentials — + // reprobe slowly instead of waiting forever for a UI nudge. this.recoveryGate = 'external-signal' this.clearTimer() + this.scheduleGateReprobe() return } if (recovery?.kind === 'disable-relay-credential') { - // Why: a rejected outer credential cannot recover until direct connectivity refreshes it. + // Why: never redial a rejected credential fast, but keep a slow reprobe + // alive — the caller re-reads durable state before each gated attempt. this.recoveryGate = 'fresh-credential' this.clearTimer() + this.scheduleGateReprobe() return } if (recovery?.kind === 'retry-after-host-offline') { @@ -235,12 +287,12 @@ export class RelayReconnectController { this.consecutiveFailures = 0 this.activeRelayConnectedAt = null this.nextAttemptAt = 0 - this.recoveryGate = null - this.clearTimer() + this.liftGate() } clear(): void { this.clearTimer() + this.gateReprobePending = false this.activeSession = null this.activeRelayConnectedAt = null } @@ -263,6 +315,45 @@ export class RelayReconnectController { }, delay) } + private scheduleGateReprobe(): void { + this.scheduleReprobeTick(this.gateReprobeDelayMs(), true) + } + + private scheduleReprobeTick(delay: number, mintToken: boolean): void { + if (this.timer) { + return + } + this.timer = this.dependencies.setTimer(() => { + this.timer = null + // Why: the streak advances once per fired tick — arm attempts within one + // cycle recompute the same delay instead of triple-escalating it. + this.gateReprobeStreak = Math.min(this.gateReprobeStreak + 1, 8) + // Why: the tick token is only minted while its gate still holds; a later + // gate must not spend a stale token and bypass its own cooldown. + if (mintToken && this.recoveryGate) { + this.gateReprobePending = true + } + this.onRetry() + }, delay) + } + + private gateReprobeDelayMs(): number { + const base = Math.min( + RELAY_GATE_REPROBE_CEILING_MS, + RELAY_GATE_REPROBE_BASE_MS * 2 ** Math.min(this.gateReprobeStreak, 8) + ) + return Math.floor(base * (0.75 + 0.5 * this.jitterFraction())) + } + + // Why: clearing a gate must also drop its timer, pending tick, and cadence — + // an orphaned reprobe timer would otherwise swallow the next fast backoff. + private liftGate(): void { + this.recoveryGate = null + this.gateReprobePending = false + this.gateReprobeStreak = 0 + this.clearTimer() + } + private delayMs(): number { const exponent = Math.max(0, this.consecutiveFailures - 1) const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent) diff --git a/mobile/src/transport/mobile-relay-recovery-log.ts b/mobile/src/transport/mobile-relay-recovery-log.ts new file mode 100644 index 00000000000..9b373cd112a --- /dev/null +++ b/mobile/src/transport/mobile-relay-recovery-log.ts @@ -0,0 +1,22 @@ +import type { ConnectionLogSink } from './types' + +export type RelayRecoveryLog = (message: string, detail?: string) => void + +// Why: relay recovery failed silently in production for weeks; every decision +// must reach logcat and the in-app connection log. +export function createRelayRecoveryLog( + now: () => number, + onLog?: ConnectionLogSink +): RelayRecoveryLog { + let sequence = 0 + return (message, detail) => { + console.log(`[relay] ${message}`, detail ?? '') + onLog?.({ + id: `relay-${++sequence}`, + ts: now(), + level: 'info', + message: `Relay: ${message}`, + ...(detail ? { detail } : {}) + }) + } +} diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts index 54ae8387411..f5fa49a785e 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -119,7 +119,7 @@ describe('mobile relay RPC session', () => { }) expect(confirmationRequest.params).not.toHaveProperty('relayDeviceId') expect(confirmationRequest.params).not.toHaveProperty('acceptedCredentialVersion') - expect(session.getLeaseExpiresAt()).toEqual(expect.any(Number)) + expect(session.getAttachDeadlineAt()).toEqual(expect.any(Number)) }) it('rejects a mismatched outer credential version and closes the physical link', () => { diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index a614e833387..ac065012203 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -19,7 +19,10 @@ type PendingRequest = { } export type MobileRelayRpcSession = RpcClient & { - getLeaseExpiresAt(): number | null + // The cell's attach-reservation deadline (~10s). Diagnostics only — never + // schedule anything from it; rotation keys off getResumeExpiresAt(). + getAttachDeadlineAt(): number | null + getResumeExpiresAt(): number | null getResumeConfirmation(): DeviceResumeConfirmed | null getFailure(): Error | null } @@ -40,7 +43,8 @@ export function connectMobileRelayRpcSession(args: { let state: ConnectionState = 'connecting' let requestCounter = 0 let lastConnectedAt: number | null = null - let leaseExpiresAt: number | null = null + let attachDeadlineAt: number | null = null + let resumeExpiresAt: number | null = null let resumeConfirmation: DeviceResumeConfirmed | null = null let failure: Error | null = null let closed = false @@ -65,7 +69,8 @@ export function connectMobileRelayRpcSession(args: { fail(new Error('relay resume credential version mismatch')) return } - leaseExpiresAt = hello.leaseExpiresAt + attachDeadlineAt = hello.leaseExpiresAt + resumeExpiresAt = hello.resumeExpiresAt publishState('handshaking') }, onAuthenticated: () => void confirmResume(), @@ -109,7 +114,8 @@ export function connectMobileRelayRpcSession(args: { streams.clear() publishState('disconnected') }, - getLeaseExpiresAt: () => leaseExpiresAt, + getAttachDeadlineAt: () => attachDeadlineAt, + getResumeExpiresAt: () => resumeExpiresAt, getResumeConfirmation: () => resumeConfirmation, getFailure: () => failure } @@ -131,6 +137,7 @@ export function connectMobileRelayRpcSession(args: { throw new Error('relay resume confirmation missing') } resumeConfirmation = result.resumeConfirmation + resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt lastConnectedAt = Date.now() publishState('connected') } catch (error) { diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts new file mode 100644 index 00000000000..dcb8ba194e2 --- /dev/null +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -0,0 +1,454 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect, type RpcClient } from './rpc-client' +import { + createStableLogicalRpcClient, + type MobileConnectionPath, + type StableLogicalRpcClient +} from './stable-logical-rpc-client' +import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' +import { RelayOuterError } from './mobile-relay-e2ee-link' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import { + MobileEndpointSupervisor, + type MobileEndpointSupervisorDependencies +} from './mobile-endpoint-supervisor' +import type { ConnectionState, HostProfile, RpcResponse } from './types' + +// Regression suite for the 2026-08 field failure: a phone paired over the +// relay whose direct LAN endpoint is unreachable (Tailscale off) dialed the +// LAN endpoint forever and never recovered a relay runtime session. + +vi.mock('react-native', () => ({ Platform: { OS: 'android' } })) +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' +})) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length) +})) + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => plaintext, + decrypt: (raw: string) => raw, + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class FakeSession implements RpcClient { + readonly sendRequest = vi.fn( + async (): Promise => ({ + id: 'rpc-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) + readonly subscribe = vi.fn(() => () => {}) + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + private readonly listeners = new Set<(state: ConnectionState) => void>() + + constructor(private state: ConnectionState) {} + + getState = () => this.state + getReconnectAttempt = () => 0 + getLastConnectedAt = () => null + onStateChange = (listener: (state: ConnectionState) => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publishState(state: ConnectionState): void { + this.state = state + for (const listener of this.listeners) { + listener(state) + } + } +} + +class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { + constructor( + state: ConnectionState, + private readonly failure: Error | null = null + ) { + super(state) + } + // Why: production-realistic constants — fictional fake values hid three + // live defects in this subsystem (latch, churn, int32 timer overflow). + getAttachDeadlineAt = () => Date.now() + 10_000 + getResumeExpiresAt = () => Date.now() + 30 * 24 * 3_600_000 + getResumeConfirmation = () => null + getFailure = () => this.failure +} + +class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { + private path: MobileConnectionPath + + constructor(state: ConnectionState, path: MobileConnectionPath) { + super(state) + this.path = path + } + + migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { + if (session.getState() !== 'connected') { + session.close() + throw new Error(`replacement session ${session.getState()}`) + } + this.path = path + this.publishState('connected') + }) + suspendActiveSession = vi.fn(() => this.publishState('disconnected')) + getActivePath = () => this.path +} + +const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} + +const DIRECT_ENDPOINT = 'ws://100.88.90.25:6768' + +const host: HostProfile = { + id: 'host-1', + name: 'Blue Whale', + endpoint: DIRECT_ENDPOINT, + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: DIRECT_ENDPOINT }, + { + id: 'relay-primary', + kind: 'relay', + url: 'wss://relay-c1.onorca.dev/v1/connect/id' + } + ], + relayHostId: relay.relayHostId, + relay +} + +function bundleWith(version: number, expiresAt: number): MobileRelayCredentialBundle { + return { + v: 1, + hostId: host.id, + deviceToken: host.deviceToken, + current: { + token: `token-v${version}`.padEnd(43, 'A'), + hash: 'B'.repeat(43), + version, + expiresAt + } + } +} + +function dependencies( + overrides: Partial = {} +): MobileEndpointSupervisorDependencies { + return { + openDirect: vi.fn(() => new FakeSession('connected')), + openRelay: vi.fn(() => new FakeRelaySession('connected')), + resolveRelay: vi.fn(async ({ relay }) => relay), + readBundle: vi.fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)), + writeBundle: vi.fn(async () => {}), + saveHost: vi.fn(async () => {}), + now: Date.now, + randomBytes: (length: number) => new Uint8Array(length), + setTimer: setTimeout, + clearTimer: clearTimeout, + ...overrides + } +} + +describe('relay runtime recovery without direct connectivity', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-02T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('recovers from a rejected outer credential once a fresher bundle is durable', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4401))) + .mockImplementation(() => new FakeRelaySession('connected')) + const readBundle = vi + .fn(async () => bundleWith(3, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(bundleWith(2, Number.MAX_SAFE_INTEGER)) + const deps = dependencies({ openRelay, readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + // Pre-fix, the fresh-credential gate latched here with no timer and no exit. + await vi.advanceTimersByTimeAsync(60_000) + expect(openRelay).toHaveBeenCalledTimes(2) + expect(openRelay).toHaveBeenLastCalledWith( + relay, + expect.objectContaining({ version: 3 }), + expect.any(String) + ) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('recovers when the credential bundle was unreadable at supervisor start', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const readBundle = vi + .fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(null) + const deps = dependencies({ readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + // Pre-fix, a null first read killed relay recovery for the process lifetime. + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + + expect(deps.openRelay).toHaveBeenCalled() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('recovers from an expired-at-start bundle after a fresh credential lands', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const expired = bundleWith(2, Date.now() - 1) + const readBundle = vi.fn(async () => expired) + const deps = dependencies({ readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(deps.openRelay).not.toHaveBeenCalled() + + readBundle.mockImplementation(async () => bundleWith(3, Number.MAX_SAFE_INTEGER)) + // Pre-fix, an expired bundle produced a silent no-op with nothing scheduled. + await vi.advanceTimersByTimeAsync(60_000) + + expect(deps.openRelay).toHaveBeenCalledOnce() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('retries after an E2EE authentication rejection without a UI nudge', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce( + new FakeRelaySession('disconnected', new MobileE2EEAuthenticationError()) + ) + .mockImplementation(() => new FakeRelaySession('connected')) + const deps = dependencies({ openRelay }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + // Pre-fix, this state waited indefinitely for a foreground/navigation event. + await vi.advanceTimersByTimeAsync(60_000) + expect(openRelay).toHaveBeenCalledTimes(2) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('adopts a durable renewal that extends expiry without bumping the version', async () => { + // Why: resume confirmations and pairing recovery renew expiresAt while + // keeping current.version — version comparison cannot see this freshness. + const logical = new FakeLogicalClient('disconnected', 'lan') + const expired = bundleWith(2, Date.now() - 1) + const readBundle = vi + .fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(expired) + .mockResolvedValueOnce(expired) + const deps = dependencies({ readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(deps.openRelay).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(60_000) + expect(deps.openRelay).toHaveBeenCalledOnce() + expect(deps.openRelay).toHaveBeenLastCalledWith( + relay, + expect.objectContaining({ version: 2 }), + expect.any(String) + ) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('adopts a re-paired credential whose version counter restarted', async () => { + // Why: re-pairing overwrites the same keychain slot with a NEW credential + // record whose counter restarts at 1 — lower than the rejected version. + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4401))) + .mockImplementation(() => new FakeRelaySession('connected')) + const readBundle = vi + .fn(async () => bundleWith(1, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(bundleWith(4, Number.MAX_SAFE_INTEGER)) + const deps = dependencies({ openRelay, readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(60_000) + expect(openRelay).toHaveBeenCalledTimes(2) + expect(openRelay).toHaveBeenLastCalledWith( + relay, + expect.objectContaining({ version: 1 }), + expect.any(String) + ) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('does not churn relay sessions off the cell attach-reservation deadline', async () => { + // Why: the relay-hello's leaseExpiresAt is a ~10s attach deadline. Keying + // rotation off it replaced the session every second, killing any RPC + // slower than the cycle (the field symptom: "Worktree list unavailable"). + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi.fn(() => new FakeRelaySession('connected')) + // Direct stays unreachable, as in the field — return probes must not + // confuse the churn measurement by migrating back to direct. + const openDirect = vi.fn(() => new FakeSession('disconnected')) + const deps = dependencies({ openRelay, openDirect }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(openRelay).toHaveBeenCalledOnce() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('recovers immediately on a background/foreground cycle after an E2EE rejection', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce( + new FakeRelaySession('disconnected', new MobileE2EEAuthenticationError()) + ) + .mockImplementation(() => new FakeRelaySession('connected')) + const deps = dependencies({ openRelay }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + supervisor.setForeground(false) + supervisor.setForeground(true) + await vi.advanceTimersByTimeAsync(0) + + expect(openRelay).toHaveBeenCalledTimes(2) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) +}) + +// The field failure's first symptom: a real direct rpc-client dialing an +// unroutable LAN endpoint (instant 1006) while relay credentials sit unused. +class DeadSocket { + static constructed: string[] = [] + onopen: (() => void) | null = null + onclose: ((event: { code?: number; reason?: string }) => void) | null = null + onerror: ((event: unknown) => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + readyState = 0 + + constructor(readonly url: string) { + DeadSocket.constructed.push(url) + setTimeout(() => { + this.readyState = 3 + this.onclose?.({ code: 1006, reason: '' }) + }, 50) + } + + send(): void {} + close(): void { + this.readyState = 3 + } +} + +describe('failover with a real direct rpc-client', () => { + beforeEach(() => { + DeadSocket.constructed = [] + vi.stubGlobal('WebSocket', DeadSocket) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-02T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('migrates the logical client to relay after the direct dial fails', async () => { + const logical = createStableLogicalRpcClient( + connect(DIRECT_ENDPOINT, host.deviceToken, host.publicKeyB64), + 'tailscale' as MobileConnectionPath + ) + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(3_000) + + expect(deps.openRelay).toHaveBeenCalled() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + logical.close() + }) + + it('reaches relay even when the bundle read settles after the first direct failure', async () => { + const logical = createStableLogicalRpcClient( + connect(DIRECT_ENDPOINT, host.deviceToken, host.publicKeyB64), + 'tailscale' as MobileConnectionPath + ) + const deps = dependencies({ + readBundle: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)) + return bundleWith(2, Number.MAX_SAFE_INTEGER) + }) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + const started = supervisor.start() + await vi.advanceTimersByTimeAsync(300) + await started + await vi.advanceTimersByTimeAsync(3_000) + + expect(deps.openRelay).toHaveBeenCalled() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + logical.close() + }) +}) From 5bd2f59d297d0374736be1ed91df9c4d1ab9d9d9 Mon Sep 17 00:00:00 2001 From: Jihwan Kim Date: Tue, 4 Aug 2026 13:41:58 +0900 Subject: [PATCH 029/101] fix(runtime): open files from sibling workspaces (#11369) * feat(runtime): match files to workspace owners * fix(runtime): resolve terminal paths through sibling workspaces * fix(editor): route restored sibling workspace files * fix remote sibling file ownership routing * fix(editor): migrate restored sibling file owners * fix(editor): revalidate restored owner activation * docs(review): record PR 11369 correction evidence * fix(editor): reject collision before activation prep * docs(review): record PR 11369 final correction * fix(editor): retain projected reconciliation narrowing * chore(review): keep verification artifacts out of PR * fix(editor): harden restored owner migration * fix(runtime): resolve workspace root terminal paths --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/reliability-gates.jsonc | 95 +++ .../mobile-native-chat-open-file.test.ts | 3 +- .../session/mobile-native-chat-open-file.ts | 38 +- .../mobile-terminal-file-tap-open.test.ts | 50 ++ .../session/mobile-terminal-file-tap-open.ts | 20 +- src/main/runtime/orca-runtime-files.test.ts | 232 +++++- src/main/runtime/orca-runtime-files.ts | 51 +- src/main/runtime/orca-runtime.ts | 71 +- .../editor/editor-autosave-controller.test.ts | 47 ++ .../editor/editor-autosave-controller.ts | 11 + .../src/components/editor/editor-autosave.ts | 8 +- .../migrate-restored-editor-file-owner.ts | 87 +++ ...stored-editor-owner-save-lifecycle.test.ts | 287 ++++++++ .../useEditorPanelContentState.test.tsx | 4 + .../editor/useEditorPanelContentState.ts | 67 +- ...torPanelRemoteSiblingContentState.test.tsx | 168 +++++ .../useEditorExternalWatch-targets.test.ts | 2 + .../src/hooks/useEditorExternalWatch.ts | 33 +- .../lib/runtime-workspace-file-route.test.ts | 114 +++ .../src/lib/runtime-workspace-file-route.ts | 76 ++ src/renderer/src/store/slices/editor.ts | 383 +++++++++- .../restored-editor-owner-reparent.test.ts | 683 ++++++++++++++++++ src/renderer/src/store/slices/tabs.ts | 426 +++++------ .../src/store/slices/worktree-helpers.ts | 13 +- src/renderer/src/store/slices/worktrees.ts | 54 +- src/shared/runtime-types.ts | 4 +- .../runtime-workspace-file-owner.test.ts | 74 ++ src/shared/runtime-workspace-file-owner.ts | 43 ++ 28 files changed, 2866 insertions(+), 278 deletions(-) create mode 100644 src/renderer/src/components/editor/migrate-restored-editor-file-owner.ts create mode 100644 src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts create mode 100644 src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx create mode 100644 src/renderer/src/lib/runtime-workspace-file-route.test.ts create mode 100644 src/renderer/src/lib/runtime-workspace-file-route.ts create mode 100644 src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts create mode 100644 src/shared/runtime-workspace-file-owner.test.ts create mode 100644 src/shared/runtime-workspace-file-owner.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c4f70fb7aea..ad6fdd1550c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,101 @@ } }, "gates": [ + { + "id": "editor.restored-sibling-owner-reparent", + "title": "Restored sibling tabs migrate filesystem authority before becoming editable", + "maturity": "experimental", + "protection": "partial", + "owner": "editor-runtime", + "layer": "renderer-store-controller-contract", + "surfaces": [ + "restored editor tabs", + "editor save and autosave", + "filesystem watches and hot-exit persistence" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "paired-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh", "paired-runtime"], + "coverageNotes": "Deterministic store/controller tests cover local, direct-SSH, paired-runtime identity, folder workspaces, queue quiescence, stale route and connection-generation rejection, authoritative active-workspace projection, owner-derived IDs, drafts and editor maps, tab/group placement, exact watch replacement, change/delete/rename correlation, explicit save, autosave, collision refusal without activation reconciliation, provenance, and restart hot-exit persistence. Live headed/headless paired-runtime and post-establish Electron IPC remain uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11304", + "https://github.com/stablyai/orca/pull/11369" + ], + "invariant": "A restored absolute path owned by a same-host sibling workspace cannot read, save, watch, or persist until old saves drain, the exact route and host generations are revalidated, and one authoritative activation transaction installs the sibling owner, target projection, editor state, reconciliation, and first-activation terminal preparation; collisions and stale routes fail before activation preparation or reconciliation.", + "oracle": "Hold an old-owner save in flight, start migration, then remove the sibling, change a folder root, or reconnect SSH and require owner migration to fail closed after quiescence. On a stable route require one coherent target-workspace projection, first-activation terminal generation preparation, destination authority for explicit save and autosave, exact watch replacement, destination-only change/delete/rename handling, owner-derived state rekeys, and hot-exit restart ownership. Seed an invalid destination tab/group model before a collision and require the target projection to remain byte-identical.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts src/renderer/src/lib/runtime-workspace-file-route.test.ts" + ], + "testFiles": [ + "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts", + "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts", + "src/renderer/src/lib/runtime-workspace-file-route.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts", + "assertions": [ + "one atomic commit rekeys owner-derived editor, preview, draft, cursor, view, tab, group, active, reveal, focus, provenance, and persistence state", + "active reparenting uses the centralized workspace activation transaction for terminal, browser, pending-creation, explorer, first-activation, and post-commit state", + "source watch unsubscribes once, destination watch subscribes once, and only destination change/delete/rename events reach the file", + "dirty and clean destination collisions fail closed without changing either open session or reconciling destination tabs and groups", + "folder and direct-SSH owners retain exact root, host, target, and connection-generation authority" + ] + }, + { + "file": "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts", + "assertions": [ + "migration waits for the old save queue and routes later explicit and automatic saves through the destination worktree", + "a removed sibling, changed folder root, or changed direct-SSH generation after quiescence rejects migration without changing ownership" + ] + }, + { + "file": "src/renderer/src/lib/runtime-workspace-file-route.test.ts", + "assertions": [ + "same-host runtime, folder, local, and direct-SSH roots resolve without cross-host fallback" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts src/renderer/src/lib/runtime-workspace-file-route.test.ts", + "result": "passed", + "durationSeconds": 3, + "summary": "Three deterministic files passed 20 lifecycle, stale-route, activation, ownership, host, collision, persistence, watch, and save tests." + } + ], + "runtimeBudget": { + "p95Seconds": 5, + "scope": "focused renderer store/controller contract on a local development runner" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "One local deterministic run is recorded; CI and soak history have not started." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The final2 verifier's byte-identical post-quiescence lifecycle scope passed 21/21 on 7890271160 and failed 7 tests on f92db196a1; base e08eba674c, published 1bcfe3bcb0, and revert b3627461ab were structurally red. The permanent collision oracle is in test blob ac62a89c671a0c8f081ebf06736715bd2150ce9f (SHA-256 a61970ace80be1cb56f781aee6f878a8446c6c4527f9eb0cd0c151d8df96bf50). The same byte-identical blob is structurally red on base, published, and revert, behaviorally red on rejected final2 candidate 7890271160 because pre-commit reconciliation removes the stale destination tab, and green on implementation b72a838cdc; the full permanent gate passes 20/20." + }, + "performanceBudget": { + "required": true, + "evidence": "Route lookup scans only the already-indexed renderer workspace catalog, while migration is O(open editor files + source/destination tabs/groups) and performs no provider call, Git scan, polling, retry, subprocess, or global fanout beyond the required old/new watch delta." + }, + "promotionCriteria": [ + "Collect two fresh child-verifier decisions against the correction candidate.", + "Collect headed and headless paired-runtime coverage after the post-establish Electron IPC gap is fixed.", + "Collect physical Windows or WSL path/host evidence and CI soak history." + ], + "knownGaps": [ + "Headed Electron post-establish IPC, paired headless, reconnect, and physical Windows/WSL journeys are uncollected.", + "Cold many-repo main-process sibling discovery remains a separately measured follow-up.", + "Registered-workspace symlink policy remains inherited and unchanged." + ], + "demotionRule": "Keep experimental or demote if migration can race a save, retain source authority, fan out watches, lose dirty/editor state, accept an owner-generation change, merge a collision, or the focused contract flakes without a product or harness bug." + }, { "id": "terminal-provider.login-session-retirement", "title": "macOS login-session retirement preserves live daemons through transient rejection bursts", diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts index 2f8ceb936a7..e2bf001ffdf 100644 --- a/mobile/src/session/mobile-native-chat-open-file.test.ts +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -36,6 +36,7 @@ describe('resolveMobileNativeChatWorktreePath', () => { .mockResolvedValueOnce({ ok: true, result: { + worktree: 'sibling-worktree', exists: true, isDirectory: false, openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } @@ -51,7 +52,7 @@ describe('resolveMobileNativeChatWorktreePath', () => { }) expect(sendRequest).toHaveBeenLastCalledWith('files.open', { - worktree: 'id:worktree', + worktree: 'id:sibling-worktree', relativePath: 'src/app.ts' }) }) diff --git a/mobile/src/session/mobile-native-chat-open-file.ts b/mobile/src/session/mobile-native-chat-open-file.ts index 44d6d23c066..6460c82849c 100644 --- a/mobile/src/session/mobile-native-chat-open-file.ts +++ b/mobile/src/session/mobile-native-chat-open-file.ts @@ -1,12 +1,17 @@ import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' import type { RpcClient } from '../transport/rpc-client' -export async function resolveMobileNativeChatWorktreePath(args: { +type MobileNativeChatWorktreeTarget = { + worktreeId: string + relativePath: string +} + +async function resolveMobileNativeChatWorktreeTarget(args: { client: RpcClient worktreeId: string pathText: string terminal: string | null -}): Promise { +}): Promise { try { const response = await args.client.sendRequest('files.resolveTerminalPath', { worktree: `id:${args.worktreeId}`, @@ -20,28 +25,39 @@ export async function resolveMobileNativeChatWorktreePath(args: { if (!resolved.exists || resolved.isDirectory) { return null } - return resolved.openTarget?.kind === 'worktree-file' - ? resolved.openTarget.relativePath - : (resolved.relativePath ?? null) + const relativePath = + resolved.openTarget?.kind === 'worktree-file' + ? resolved.openTarget.relativePath + : (resolved.relativePath ?? null) + return relativePath + ? { worktreeId: resolved.worktree?.trim() || args.worktreeId, relativePath } + : null } catch { - // Callers fire-and-forget file opens; a disconnect/timeout must not become - // an unhandled rejection. return null } } +export async function resolveMobileNativeChatWorktreePath(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + return (await resolveMobileNativeChatWorktreeTarget(args))?.relativePath ?? null +} + export async function openMobileNativeChatFile(args: { client: RpcClient worktreeId: string pathText: string terminal: string | null }): Promise { - const relativePath = await resolveMobileNativeChatWorktreePath(args) - if (relativePath) { + const target = await resolveMobileNativeChatWorktreeTarget(args) + if (target) { try { await args.client.sendRequest('files.open', { - worktree: `id:${args.worktreeId}`, - relativePath + worktree: `id:${target.worktreeId}`, + relativePath: target.relativePath }) } catch { // Best-effort open; failures surface as a no-op rather than an diff --git a/mobile/src/session/mobile-terminal-file-tap-open.test.ts b/mobile/src/session/mobile-terminal-file-tap-open.test.ts index fec1b5b663c..a958b977260 100644 --- a/mobile/src/session/mobile-terminal-file-tap-open.test.ts +++ b/mobile/src/session/mobile-terminal-file-tap-open.test.ts @@ -135,6 +135,56 @@ describe('openMobileTerminalFileTap', () => { expect(switchSessionTab).toHaveBeenCalledWith(openedTab) }) + it('opens a sibling terminal path through the resolved owning worktree', async () => { + const client = createClient([ + ok({ + worktree: 'wt-2', + relativePath: 'docs/readme.md', + absolutePath: '/repo-b/docs/readme.md', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'docs/readme.md', + absolutePath: '/repo-b/docs/readme.md' + } + }) + ]) + const pushPreviewRoute = vi.fn() + + openMobileTerminalFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: '/repo-b/docs/readme.md', + line: null, + column: null, + pushPreviewRoute, + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: activeTerminalState, + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn() + }) + await Promise.resolve() + await Promise.resolve() + + expect(pushPreviewRoute).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/files/preview/[worktreeId]', + params: expect.objectContaining({ + hostId: 'host-1', + worktreeId: 'wt-2', + source: 'worktree', + relativePath: 'docs/readme.md' + }) + }) + expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything()) + }) + it('opens worktree-contained line references through the preview route', async () => { const client = createClient([ ok({ diff --git a/mobile/src/session/mobile-terminal-file-tap-open.ts b/mobile/src/session/mobile-terminal-file-tap-open.ts index 31a5c892d75..8d6e681b0f4 100644 --- a/mobile/src/session/mobile-terminal-file-tap-open.ts +++ b/mobile/src/session/mobile-terminal-file-tap-open.ts @@ -74,13 +74,17 @@ async function openMobileTerminalFileTapAsync) { function createRuntimeFileCommands(options?: { path?: string + hostId?: string openFile?: ReturnType openDiff?: ReturnType resolveRuntimeFileTarget?: ReturnType + resolveKnownWorkspaceFileTarget?: ReturnType resolveRuntimeGitTarget?: ReturnType resolveTerminalCwd?: ReturnType resolveTerminalContext?: ReturnType @@ -157,7 +159,8 @@ function createRuntimeFileCommands(options?: { const worktree = { id: 'wt-1', repoId: 'repo-1', - path + path, + ...(options?.hostId ? { hostId: options.hostId } : {}) } const commands = new RuntimeFileCommands({ getRuntimeId: () => 'runtime-1', @@ -169,6 +172,9 @@ function createRuntimeFileCommands(options?: { worktree, connectionId: store.getRepo(worktree.repoId)?.connectionId })), + ...(options?.resolveKnownWorkspaceFileTarget + ? { resolveKnownWorkspaceFileTarget: options.resolveKnownWorkspaceFileTarget } + : {}), resolveTerminalCwd: options?.resolveTerminalCwd ?? vi.fn(() => path), resolveTerminalContext: options?.resolveTerminalContext ?? @@ -882,6 +888,194 @@ describe('RuntimeFileCommands', () => { }) }) + it('resolves an absolute path through a known sibling workspace', async () => { + const sibling = { + id: 'wt-2', + repoId: 'repo-2', + path: '/sibling', + git: { + path: '/sibling', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } + } + const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ + worktree: sibling, + relativePath: 'docs/readme.md' + })) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + resolveKnownWorkspaceFileTarget + }) + statAsFile() + + const result = await commands.resolveTerminalPath('id:wt-1', '/sibling/docs/readme.md') + + expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith( + '/sibling/docs/readme.md', + 'local' + ) + expect(result).toEqual({ + worktree: 'wt-2', + relativePath: 'docs/readme.md', + absolutePath: '/sibling/docs/readme.md', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'docs/readme.md', + absolutePath: '/sibling/docs/readme.md' + } + }) + }) + + it('resolves an exact local sibling workspace root as a directory without a grant', async () => { + const sibling = { + id: 'wt-2', + repoId: 'repo-2', + path: '/sibling', + git: { + path: '/sibling', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } + } + const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ + worktree: sibling, + relativePath: '' + })) + const hasRecentTerminalOutputPath = vi.fn(() => true) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + resolveKnownWorkspaceFileTarget, + hasRecentTerminalOutputPath + }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockResolvedValue({ isDirectory: () => true }) + + const result = await resolveTerminalArtifactPath(commands, '/sibling') + + expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith('/sibling', 'local') + expect(result).toEqual({ + worktree: 'wt-2', + relativePath: '', + absolutePath: '/sibling', + exists: true, + isDirectory: true, + openTarget: undefined + }) + expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled() + }) + + it('stats a sibling SSH workspace through its owning provider', async () => { + const sibling = { + id: 'wt-2', + repoId: 'repo-2', + path: '/sibling', + hostId: 'ssh:ssh-1', + git: { + path: '/sibling', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } + } + const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ + worktree: sibling, + connectionId: 'ssh-1', + relativePath: 'docs/readme.md' + })) + const { commands, store } = createRuntimeFileCommands({ + path: '/repo', + resolveKnownWorkspaceFileTarget + }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const remoteStat = vi.fn().mockResolvedValue({ type: 'file', size: 12, mtime: 3 }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat: remoteStat } as never) + + const result = await commands.resolveTerminalPath('id:wt-1', '/sibling/docs/readme.md') + + expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith( + '/sibling/docs/readme.md', + 'ssh:ssh-1' + ) + expect(remoteStat).toHaveBeenCalledWith('/sibling/docs/readme.md') + expect(statMock).not.toHaveBeenCalled() + expect(result).toMatchObject({ + worktree: 'wt-2', + relativePath: 'docs/readme.md', + exists: true, + openTarget: { provider: 'ssh' } + }) + }) + + it('stats an exact SSH sibling root as a directory through its owning provider', async () => { + const sibling = { + id: 'wt-2', + repoId: 'repo-2', + path: '/sibling', + hostId: 'ssh:ssh-1', + git: { + path: '/sibling', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } + } + const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ + worktree: sibling, + connectionId: 'ssh-1', + relativePath: '' + })) + const hasRecentTerminalOutputPath = vi.fn(() => true) + const { commands, store } = createRuntimeFileCommands({ + path: '/repo', + resolveKnownWorkspaceFileTarget, + hasRecentTerminalOutputPath + }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const remoteStat = vi.fn().mockResolvedValue({ type: 'directory', size: 0, mtime: 3 }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat: remoteStat } as never) + + const result = await resolveTerminalArtifactPath(commands, '/sibling') + + expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith('/sibling', 'ssh:ssh-1') + expect(remoteStat).toHaveBeenCalledWith('/sibling') + expect(statMock).not.toHaveBeenCalled() + expect(result).toEqual({ + worktree: 'wt-2', + relativePath: '', + absolutePath: '/sibling', + exists: true, + isDirectory: true, + openTarget: undefined + }) + expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled() + }) + + it('scopes sibling lookup to the selected worktree execution host', async () => { + const resolveKnownWorkspaceFileTarget = vi.fn(async () => null) + const { commands } = createRuntimeFileCommands({ + path: '/repo-a', + hostId: 'runtime:env-a', + resolveKnownWorkspaceFileTarget + }) + + await commands.resolveTerminalPath('id:wt-1', '/repo-b/docs/readme.md') + + expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith( + '/repo-b/docs/readme.md', + 'runtime:env-a' + ) + }) + it('resolves a relative path against the provided cwd', async () => { const { commands } = createRuntimeFileCommands({ path: '/repo' }) statAsFile() @@ -950,14 +1144,44 @@ describe('RuntimeFileCommands', () => { expect(result).toMatchObject({ relativePath: 'docs/readme.md', exists: true }) }) - it('reports a directory', async () => { + it('reports directories including the workspace root', async () => { const { commands } = createRuntimeFileCommands({ path: '/repo' }) resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) statMock.mockResolvedValue({ isDirectory: () => true }) - const result = await commands.resolveTerminalPath('id:wt-1', '/repo/src') + const nestedResult = await commands.resolveTerminalPath('id:wt-1', '/repo/src') + const rootResult = await commands.resolveTerminalPath('id:wt-1', '/repo') - expect(result).toMatchObject({ relativePath: 'src', isDirectory: true, exists: true }) + expect(nestedResult).toMatchObject({ relativePath: 'src', isDirectory: true, exists: true }) + expect(rootResult).toMatchObject({ + relativePath: '', + isDirectory: true, + exists: true, + openTarget: undefined + }) + }) + + it('keeps an exact selected nested root instead of resolving it to a parent workspace', async () => { + const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-parent', repoId: 'repo-parent', path: '/repo' }, + relativePath: 'nested' + })) + const { commands } = createRuntimeFileCommands({ + path: '/repo/nested', + resolveKnownWorkspaceFileTarget + }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockResolvedValue({ isDirectory: () => true }) + + const result = await commands.resolveTerminalPath('id:wt-1', '/repo/nested') + + expect(resolveKnownWorkspaceFileTarget).not.toHaveBeenCalled() + expect(result).toMatchObject({ + worktree: 'wt-1', + relativePath: '', + isDirectory: true, + openTarget: undefined + }) }) it('returns an absolute open target for an existing local temp path outside the worktree', async () => { diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 1ea6769ff4c..08f18da2de0 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -87,7 +87,7 @@ import { } from './runtime-mobile-file-path-search' import { beginWatcherInstall } from '../ipc/watcher-removal-gate' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' -import { toSshExecutionHostId } from '../../shared/execution-host' +import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' const MOBILE_FILE_LIST_LIMIT = 5000 @@ -490,11 +490,24 @@ export type ResolvedRuntimeFileTarget = { connectionId?: string } +export function getRuntimeFileTargetExecutionHostId( + target: ResolvedRuntimeFileTarget +): ExecutionHostId { + return ( + target.worktree.hostId ?? + (target.connectionId ? toSshExecutionHostId(target.connectionId) : 'local') + ) +} + export type RuntimeFileCommandHost = { getRuntimeId(): string requireStore(): Store resolveWorktreeSelector(selector: string): Promise resolveRuntimeFileTarget(selector: string): Promise + resolveKnownWorkspaceFileTarget?( + absolutePath: string, + executionHostId: ExecutionHostId + ): Promise<(ResolvedRuntimeFileTarget & { relativePath: string }) | null> resolveTerminalCwd?(terminalHandle: string): string | null | Promise resolveTerminalContext?( terminalHandle: string @@ -744,15 +757,28 @@ export class RuntimeFileCommands { terminalFileUriHostname }) const relativePath = relativePathInsideRoot(worktree.path, absolutePath) + const knownWorkspaceTarget = + relativePath === null + ? await this.host.resolveKnownWorkspaceFileTarget?.( + absolutePath, + getRuntimeFileTargetExecutionHostId(target) + ) + : null + const ownedWorktree = knownWorkspaceTarget?.worktree ?? worktree + const ownedConnectionId = knownWorkspaceTarget?.connectionId ?? connectionId + const ownedRelativePath = knownWorkspaceTarget?.relativePath ?? relativePath try { - if (relativePath !== null && relativePath !== '' && isSafeMobileRelativePath(relativePath)) { - const stats = connectionId - ? await this.statRemoteTerminalPath(absolutePath, connectionId) + if ( + ownedRelativePath !== null && + (ownedRelativePath === '' || isSafeMobileRelativePath(ownedRelativePath)) + ) { + const stats = ownedConnectionId + ? await this.statRemoteTerminalPath(absolutePath, ownedConnectionId) : await stat(await resolveAuthorizedPath(absolutePath, store)) return { - worktree: worktree.id, - relativePath, + worktree: ownedWorktree.id, + relativePath: ownedRelativePath, absolutePath, exists: true, isDirectory: stats.isDirectory(), @@ -760,8 +786,8 @@ export class RuntimeFileCommands { ? undefined : { kind: 'worktree-file', - provider: connectionId ? 'ssh' : 'local', - relativePath, + provider: ownedConnectionId ? 'ssh' : 'local', + relativePath: ownedRelativePath, absolutePath } } @@ -832,9 +858,14 @@ export class RuntimeFileCommands { // Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing. if ( isENOENT(error) || - (connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) + (ownedConnectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) ) { - return { ...empty, relativePath, absolutePath } + return { + ...empty, + worktree: ownedWorktree.id, + relativePath: ownedRelativePath, + absolutePath + } } throw error } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c94d222de37..088f52d5b26 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -425,6 +425,7 @@ import { isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { findRuntimeWorkspaceFileOwner } from '../../shared/runtime-workspace-file-owner' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths' import { @@ -515,7 +516,7 @@ import { } from './workspace-session-terminal-membership-authority' import { RuntimeEmulatorCommands } from './orca-runtime-emulator' import type { EmulatorBridge } from '../emulator/emulator-bridge' -import { RuntimeFileCommands } from './orca-runtime-files' +import { getRuntimeFileTargetExecutionHostId, RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' import { activateClientSessionTabSelection, @@ -8542,6 +8543,8 @@ export class OrcaRuntimeService { requireStore: () => this.requireStore(), resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector), + resolveKnownWorkspaceFileTarget: (absolutePath, connectionId) => + this.resolveKnownWorkspaceFileTarget(absolutePath, connectionId), resolveTerminalCwd: (terminalHandle) => this.resolveTerminalCwd(terminalHandle), resolveTerminalContext: (terminalHandle) => this.resolveTerminalContext(terminalHandle), resolveTerminalFileUriHostname: (terminalHandle) => @@ -8859,6 +8862,72 @@ export class OrcaRuntimeService { return { worktree, connectionId: repo?.connectionId ?? undefined } } + private async resolveKnownWorkspaceFileTarget( + absolutePath: string, + executionHostId: ExecutionHostId + ): Promise<{ + worktree: ResolvedWorktree + connectionId?: string + relativePath: string + } | null> { + const targets = new Map< + string, + { + worktree: ResolvedWorktree + connectionId?: string + executionHostId: ExecutionHostId + } + >() + for (const worktree of await this.listResolvedWorktrees()) { + if (!this.isRuntimeWorktreeVisible(worktree)) { + continue + } + const candidateConnectionId = this.store?.getRepo(worktree.repoId)?.connectionId ?? undefined + const target = { + worktree, + executionHostId: getRuntimeFileTargetExecutionHostId({ + worktree, + connectionId: candidateConnectionId + }), + ...(candidateConnectionId ? { connectionId: candidateConnectionId } : {}) + } + targets.set(`${target.executionHostId}\0${worktree.id}`, target) + } + for (const folderWorkspace of this.store?.getFolderWorkspaces?.() ?? []) { + try { + const candidateConnectionId = + this.resolveFolderWorkspaceConnectionId(folderWorkspace) ?? undefined + const worktree = this.folderWorkspaceToResolvedWorktree(folderWorkspace) + const target = { + worktree, + executionHostId: getRuntimeFileTargetExecutionHostId({ + worktree, + connectionId: candidateConnectionId + }), + ...(candidateConnectionId ? { connectionId: candidateConnectionId } : {}) + } + targets.set(`${target.executionHostId}\0${worktree.id}`, target) + } catch { + // An ambiguous folder workspace has no single filesystem authority. + } + } + + const owner = findRuntimeWorkspaceFileOwner( + [...targets.values()].map((target) => ({ + workspaceId: target.worktree.id, + rootPath: target.worktree.path, + executionHostId: target.executionHostId + })), + absolutePath, + executionHostId + ) + if (!owner) { + return null + } + const target = targets.get(`${owner.executionHostId}\0${owner.workspaceId}`) + return target ? { ...target, relativePath: owner.relativePath } : null + } + onMobileSessionTabsChanged( listener: (snapshot: RuntimeMobileSessionTabsResult) => void, clientNavigationId?: string diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index 5020bf5e8f8..78e83706e52 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -511,6 +511,53 @@ describe('attachEditorAutosaveController', () => { } }) + it('drops an autosave already queued when owner migration starts', async () => { + let releaseFirstWrite!: () => void + const firstWrite = new Promise((resolve) => { + releaseFirstWrite = resolve + }) + const writeFile = vi.fn().mockReturnValueOnce(firstWrite).mockResolvedValue(undefined) + const eventTarget = new EventTarget() + vi.stubGlobal('window', { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { fs: { writeFile } } + } satisfies WindowStub) + + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + const cleanup = attachEditorAutosaveController(store) + try { + store.getState().setEditorDraft('/repo/file.ts', 'first autosave') + store.getState().markFileDirty('/repo/file.ts', true) + await vi.advanceTimersByTimeAsync(1000) + expect(writeFile).toHaveBeenCalledTimes(1) + + store.getState().setEditorDraft('/repo/file.ts', 'queued autosave') + store.getState().markFileDirty('/repo/file.ts', true) + await vi.advanceTimersByTimeAsync(1000) + store.getState().setRestoredEditorOwnerMigrationPending('/repo/file.ts', true) + releaseFirstWrite() + for (let index = 0; index < 6; index += 1) { + await Promise.resolve() + } + + expect(writeFile).toHaveBeenCalledTimes(1) + expect(store.getState().editorDrafts['/repo/file.ts']).toBe('queued autosave') + } finally { + cleanup() + } + }) + it('leaves dirty editor drafts ready for the combined hot-exit checkpoint', async () => { const writeFile = vi.fn().mockResolvedValue(undefined) const setSync = vi.fn() diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 3326d93492d..271786a74c1 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -92,6 +92,13 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { return } + if (liveFile.pendingOwnerMigration === true) { + if (trigger === 'autosave') { + return + } + throw new Error('This file is still restoring its workspace owner. Try saving again.') + } + // Why: only autosave is blocked while suspended; explicit user saves proceed (the banner warned). if (trigger === 'autosave' && isAutosaveSuspendedForFile(liveFile)) { return @@ -341,6 +348,10 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { detail.resolve() return } + if (file.pendingOwnerMigration === true) { + detail.reject('This file is still restoring its workspace owner. Try saving again.') + return + } flushPendingEditorChange(file.id) diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index 01ac40151e5..efeda658724 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -89,13 +89,17 @@ export function canAutoSaveOpenFile(file: OpenFile): boolean { export function isAutosaveSuspendedForFile( file: Pick< OpenFile, - 'externalMutation' | 'pendingDiskBaselineVerification' | 'pendingLiveDiskVerification' + | 'externalMutation' + | 'pendingDiskBaselineVerification' + | 'pendingLiveDiskVerification' + | 'pendingOwnerMigration' > ): boolean { return ( file.externalMutation === 'changed' || file.pendingDiskBaselineVerification === true || - file.pendingLiveDiskVerification === true + file.pendingLiveDiskVerification === true || + file.pendingOwnerMigration === true ) } diff --git a/src/renderer/src/components/editor/migrate-restored-editor-file-owner.ts b/src/renderer/src/components/editor/migrate-restored-editor-file-owner.ts new file mode 100644 index 00000000000..86fbbfa63f1 --- /dev/null +++ b/src/renderer/src/components/editor/migrate-restored-editor-file-owner.ts @@ -0,0 +1,87 @@ +import { useAppStore } from '@/store' +import { + findWorkspaceFileRoute, + type RuntimeWorkspaceFileRoute +} from '@/lib/runtime-workspace-file-route' +import { + assertEditorFileOperationCurrent, + captureEditorFileOperationProvenance, + type EditorFileOperationProvenance +} from '@/lib/editor-file-operation-owner' +import { requestEditorSaveQuiesce } from './editor-autosave' +import type { RestoredEditorOwnerResult } from '@/store/slices/editor' + +export type RestoredEditorOwnerMigrationResult = RestoredEditorOwnerResult + +export async function migrateRestoredEditorFileOwner( + fileId: string, + route: RuntimeWorkspaceFileRoute, + runtimeEnvironmentId: string | null +): Promise { + const state = useAppStore.getState() + const source = state.openFiles.find((file) => file.id === fileId) + const initialRoute = source + ? findWorkspaceFileRoute(state, route.executionHostId, source.filePath) + : null + if (!source || !routesMatch(initialRoute, route)) { + return { ok: false, reason: 'stale' } + } + let targetOperationProvenance: EditorFileOperationProvenance + try { + targetOperationProvenance = captureEditorFileOperationProvenance( + state, + route.worktreeId, + runtimeEnvironmentId, + true + ) + } catch { + return { ok: false, reason: 'owner-changed' } + } + if (!state.setRestoredEditorOwnerMigrationPending(fileId, true)) { + return { ok: false, reason: 'stale' } + } + + try { + await requestEditorSaveQuiesce({ fileId }) + } catch (error) { + useAppStore.getState().setRestoredEditorOwnerMigrationPending(fileId, false) + throw error + } + const currentState = useAppStore.getState() + const currentSource = currentState.openFiles.find((file) => file.id === fileId) + const currentRoute = currentSource + ? findWorkspaceFileRoute(currentState, route.executionHostId, currentSource.filePath) + : null + try { + if ( + currentSource?.filePath !== source.filePath || + !routesMatch(currentRoute, route) || + assertEditorFileOperationCurrent(currentState, route.worktreeId, targetOperationProvenance) + .runtimeEnvironmentId !== runtimeEnvironmentId + ) { + throw new Error('stale route') + } + } catch { + currentState.setRestoredEditorOwnerMigrationPending(fileId, false) + return { ok: false, reason: 'owner-changed' } + } + return currentState.reparentRestoredEditorFileOwner({ + fileId, + targetWorktreeId: route.worktreeId, + targetRelativePath: route.relativePath, + targetExecutionHostId: route.executionHostId, + targetRuntimeEnvironmentId: runtimeEnvironmentId, + targetOperationProvenance + }) +} + +function routesMatch( + current: RuntimeWorkspaceFileRoute | null, + expected: RuntimeWorkspaceFileRoute +): boolean { + return ( + current?.worktreeId === expected.worktreeId && + current.relativePath === expected.relativePath && + current.executionHostId === expected.executionHostId + ) +} diff --git a/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts b/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts new file mode 100644 index 00000000000..1d5ccf262b3 --- /dev/null +++ b/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts @@ -0,0 +1,287 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeFileClient from '@/runtime/runtime-file-client' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { attachEditorAutosaveController } from './editor-autosave-controller' +import { requestEditorFileSave } from './editor-autosave' +import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner' + +const mocks = vi.hoisted(() => ({ writeRuntimeFile: vi.fn() })) + +vi.mock('@/runtime/runtime-file-client', async (importOriginal) => ({ + ...(await importOriginal()), + writeRuntimeFile: mocks.writeRuntimeFile +})) + +const SOURCE = 'repo-a::/repo-a' +const TARGET = 'repo-b::/repo-b' +const FILE_PATH = '/repo-b/file.md' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +function seed(filePath = FILE_PATH): string { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + activeWorktreeId: SOURCE, + settings: { editorAutoSave: true, editorAutoSaveDelayMs: 250 }, + repos: [ + { id: 'repo-a', path: '/repo-a', kind: 'git', executionHostId: 'local' }, + { id: 'repo-b', path: '/repo-b', kind: 'git', executionHostId: 'local' } + ], + worktreesByRepo: { + 'repo-a': [{ id: SOURCE, repoId: 'repo-a', path: '/repo-a', hostId: 'local', branch: '' }], + 'repo-b': [{ id: TARGET, repoId: 'repo-b', path: '/repo-b', hostId: 'local', branch: '' }] + }, + detectedWorktreesByRepo: {}, + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: true, + removedRuntimeEnvironmentIds: new Set(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map() + } as unknown as Partial) + return useAppStore.getState().openFile( + { + filePath, + relativePath: filePath, + worktreeId: SOURCE, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) +} + +describe('restored editor owner save lifecycle', () => { + let detach: (() => void) | null = null + + beforeEach(() => { + vi.useFakeTimers() + mocks.writeRuntimeFile.mockReset() + }) + + afterEach(() => { + detach?.() + detach = null + vi.useRealTimers() + }) + + it('drains the old save before reparent, then routes explicit save and autosave to the destination', async () => { + const oldId = seed() + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise).mockResolvedValue(undefined) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { + worktreeId: TARGET, + relativePath: 'file.md', + executionHostId: 'local' + }, + null + ) + await Promise.resolve() + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBe(true) + expect(useAppStore.getState().openFiles[0]?.worktreeId).toBe(SOURCE) + await expect(requestEditorFileSave({ fileId: oldId })).rejects.toThrow( + 'still restoring its workspace owner' + ) + + firstWrite.resolve() + await sourceSave + const migrated = await migration + expect(migrated.ok).toBe(true) + if (!migrated.ok) { + return + } + expect(mocks.writeRuntimeFile.mock.calls[0]?.[0]).toMatchObject({ worktreeId: SOURCE }) + + useAppStore.getState().setEditorDraft(migrated.fileId, 'explicit destination save') + useAppStore.getState().markFileDirty(migrated.fileId, true) + await requestEditorFileSave({ fileId: migrated.fileId }) + expect(mocks.writeRuntimeFile.mock.calls[1]?.[0]).toMatchObject({ worktreeId: TARGET }) + + useAppStore.getState().setEditorDraft(migrated.fileId, 'autosave destination') + useAppStore.getState().markFileDirty(migrated.fileId, true) + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(3)) + expect(mocks.writeRuntimeFile.mock.calls[2]?.[0]).toMatchObject({ worktreeId: TARGET }) + expect(mocks.writeRuntimeFile.mock.calls[2]?.[2]).toBe('autosave destination') + }) + + it('rejects a concurrent migration without clearing the active migration gate', async () => { + const oldId = seed() + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const route = { worktreeId: TARGET, relativePath: 'file.md', executionHostId: 'local' as const } + const firstMigration = migrateRestoredEditorFileOwner(oldId, route, null) + await Promise.resolve() + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBe(true) + + await expect(migrateRestoredEditorFileOwner(oldId, route, null)).resolves.toEqual({ + ok: false, + reason: 'stale' + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBe(true) + + firstWrite.resolve() + await sourceSave + await expect(firstMigration).resolves.toMatchObject({ ok: true }) + }) + + it('fails closed when the sibling workspace disappears during save quiescence', async () => { + const oldId = seed() + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { worktreeId: TARGET, relativePath: 'file.md', executionHostId: 'local' }, + null + ) + await Promise.resolve() + useAppStore.setState((state) => ({ + worktreesByRepo: { ...state.worktreesByRepo, 'repo-b': [] } + })) + + firstWrite.resolve() + await sourceSave + await expect(migration).resolves.toEqual({ ok: false, reason: 'owner-changed' }) + expect(useAppStore.getState().openFiles[0]).toMatchObject({ + id: oldId, + worktreeId: SOURCE + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBeUndefined() + }) + + it('fails closed when a folder root changes during save quiescence', async () => { + const oldId = seed('/notes/todo.md') + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'notes', + projectGroupId: 'notes-group', + folderPath: '/notes', + connectionId: null, + executionHostId: 'local' + } + ], + projectGroups: [{ id: 'notes-group', name: 'Notes', executionHostId: 'local' }] + } as unknown as Partial) + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { worktreeId: 'folder:notes', relativePath: 'todo.md', executionHostId: 'local' }, + null + ) + await Promise.resolve() + useAppStore.setState((state) => ({ + folderWorkspaces: state.folderWorkspaces.map((workspace) => ({ + ...workspace, + folderPath: '/renamed-notes' + })) + })) + + firstWrite.resolve() + await sourceSave + await expect(migration).resolves.toEqual({ ok: false, reason: 'owner-changed' }) + expect(useAppStore.getState().openFiles[0]).toMatchObject({ + id: oldId, + worktreeId: SOURCE + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBeUndefined() + }) + + it('fails closed when direct-SSH authority reconnects during save quiescence', async () => { + const oldId = seed() + useAppStore.setState((state) => ({ + repos: state.repos.map((repo) => + repo.id === 'repo-b' + ? { ...repo, connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' } + : repo + ), + worktreesByRepo: { + ...state.worktreesByRepo, + 'repo-b': state.worktreesByRepo['repo-b'].map((worktree) => ({ + ...worktree, + hostId: 'ssh:ssh-1' + })) + } + })) + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + }) + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { worktreeId: TARGET, relativePath: 'file.md', executionHostId: 'ssh:ssh-1' }, + null + ) + await Promise.resolve() + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'disconnected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + }) + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 2 + }) + + firstWrite.resolve() + await sourceSave + await expect(migration).resolves.toEqual({ ok: false, reason: 'owner-changed' }) + expect(useAppStore.getState().openFiles[0]).toMatchObject({ + id: oldId, + worktreeId: SOURCE + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 3920caa89a8..3a5ed862d8e 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -41,6 +41,10 @@ vi.mock('@/lib/connection-context', () => ({ isWorktreeConnectionResolved: mocks.isWorktreeConnectionResolved })) +vi.mock('@/lib/runtime-workspace-file-route', () => ({ + findWorkspaceFileRoute: vi.fn(() => null) +})) + vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index d7c62b6e6ed..f9cda5dc753 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -9,6 +9,12 @@ import { useAppStore } from '@/store' import { getDiskBaselineSignature } from './diff-content-signature' import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' import { getRuntimeGitBranchDiff, getRuntimeGitCommitDiff, @@ -31,6 +37,7 @@ import { } from './useEditorPanelExternalContentEvents' import { useEditorPanelFileLoadRetry } from './useEditorPanelFileLoadRetry' import { useLocalLogTail } from './useLocalLogTail' +import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner' const inFlightFileReads = new Map>() const inFlightDiffReads = new Map>() @@ -143,6 +150,8 @@ export function useEditorPanelContentState({ const isLiveTailLogTab = restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true let readConnectionId = connectionId + let readWorktreeId = worktreeId + let readRelativePath = restoredOpenFile?.relativePath ?? relativePath if ( resolvedConnectionId === undefined && !readSettings?.activeRuntimeEnvironmentId?.trim() && @@ -160,18 +169,52 @@ export function useEditorPanelContentState({ const externalSshOwnerId = restoredOpenFile.externalSshTargetId?.trim() || (isLiveTailLogTab ? undefined : connectionId) - if (!externalSshOwnerId && readSettings?.activeRuntimeEnvironmentId?.trim()) { - // Why: runtime file RPCs are worktree-scoped, so a client-local absolute - // path has no route into a remote runtime workspace. - throw new Error('External local files are not available for remote workspaces.') - } - if (!externalSshOwnerId) { - // Why: client-local external tabs need their main-process path grant - // refreshed because that authorization is only held in memory. + const runtimeEnvironmentId = isLiveTailLogTab + ? undefined + : readSettings?.activeRuntimeEnvironmentId?.trim() + if (isLiveTailLogTab) { await window.api.fs.authorizeExternalPath({ targetPath: filePath }) - // Why: that grant covers the client path, so this read must stay off the - // worktree's SSH host. readConnectionId = undefined + } else { + const currentState = useAppStore.getState() + const executionHostId = externalSshOwnerId + ? toSshExecutionHostId(externalSshOwnerId) + : runtimeEnvironmentId + ? toRuntimeExecutionHostId(runtimeEnvironmentId) + : LOCAL_EXECUTION_HOST_ID + const route = findWorkspaceFileRoute(currentState, executionHostId, filePath) + if (route && route.worktreeId !== worktreeId) { + const migration = await migrateRestoredEditorFileOwner( + id, + route, + runtimeEnvironmentId ?? null + ) + fileReadGenerationRef.current[id] = ++fileReadGenerationCounterRef.current + if (!migration.ok) { + throw new Error( + migration.reason === 'collision' + ? 'The sibling file is already open; close one tab before restoring it.' + : 'The sibling file owner changed while the tab was restoring.' + ) + } + setFileContents((prev) => { + const next = { ...prev } + delete next[id] + return next + }) + return + } + if (runtimeEnvironmentId && !route) { + throw new Error('External local files are not available for remote workspaces.') + } + if (!externalSshOwnerId) { + // Why: client-local external tabs need their main-process path grant + // refreshed because that authorization is only held in memory. + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + // Why: that grant covers the client path, so this read must stay off the + // worktree's SSH host. + readConnectionId = undefined + } } } const readScope = getRuntimeFileReadScope(readSettings, readConnectionId) @@ -186,8 +229,8 @@ export function useEditorPanelContentState({ pending = readRuntimeFileContent({ settings: readSettings, filePath, - relativePath: restoredOpenFile?.relativePath ?? relativePath, - worktreeId, + relativePath: readRelativePath, + worktreeId: readWorktreeId, connectionId: readConnectionId, expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId, includeLocalLogMetadata: isLiveTailLogTab diff --git a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx new file mode 100644 index 00000000000..4576b137432 --- /dev/null +++ b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx @@ -0,0 +1,168 @@ +// @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 type { OpenFile } from '@/store/slices/editor' +import type { FileContent } from './editor-panel-content-types' + +const mocks = vi.hoisted(() => ({ + readRuntimeFileContent: vi.fn(), + findWorkspaceFileRoute: vi.fn(), + migrateRestoredEditorFileOwner: vi.fn(), + getState: vi.fn() +})) + +vi.mock('@/runtime/runtime-file-client', () => ({ + getRuntimeFileReadScope: vi.fn( + ( + settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined, + connectionId?: string + ) => connectionId ?? settings?.activeRuntimeEnvironmentId ?? null + ), + readRuntimeFileContent: mocks.readRuntimeFileContent, + subscribeRuntimeFileChanges: vi.fn() +})) + +vi.mock('@/runtime/runtime-git-client', () => ({ + getRuntimeGitBranchDiff: vi.fn(), + getRuntimeGitCommitDiff: vi.fn(), + getRuntimeGitDiff: vi.fn(), + getRuntimeGitScope: vi.fn(() => null) +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: vi.fn(), + getConnectionIdForFile: vi.fn(), + isWorktreeConnectionResolved: vi.fn(() => true) +})) + +vi.mock('@/lib/runtime-workspace-file-route', () => ({ + findWorkspaceFileRoute: mocks.findWorkspaceFileRoute +})) + +vi.mock('./migrate-restored-editor-file-owner', () => ({ + migrateRestoredEditorFileOwner: mocks.migrateRestoredEditorFileOwner +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: vi.fn(() => 'local') +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) + +vi.mock('./useEditorPanelExternalContentEvents', () => ({ + useEditorPanelExternalContentEvents: vi.fn(), + usePruneClosedEditorContent: vi.fn() +})) + +vi.mock('./useEditorPanelFileLoadRetry', () => ({ useEditorPanelFileLoadRetry: vi.fn() })) +vi.mock('./useLocalLogTail', () => ({ useLocalLogTail: vi.fn() })) + +import { useEditorPanelContentState } from './useEditorPanelContentState' + +const authorizeExternalPath = vi.fn() +let latestFileContents: Record = {} + +function createOpenFile(overrides: Partial): OpenFile { + return { + id: '/repo/file.ts', + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + isDirty: false, + mode: 'edit', + ...overrides + } +} + +function HookProbe({ activeFile }: { activeFile: OpenFile }): null { + latestFileContents = useEditorPanelContentState({ + activeFile, + isChangesMode: false, + openFiles: [activeFile], + gitStatusEntries: undefined, + editorViewMode: {} + }).fileContents + return null +} + +describe('remote sibling editor content routing', () => { + let container: HTMLDivElement | null = null + let root: Root | null = null + + beforeEach(() => { + latestFileContents = {} + authorizeExternalPath.mockReset() + authorizeExternalPath.mockResolvedValue(undefined) + ;(window as unknown as { api: unknown }).api = { fs: { authorizeExternalPath } } + mocks.readRuntimeFileContent.mockReset() + mocks.findWorkspaceFileRoute.mockReset() + mocks.findWorkspaceFileRoute.mockReturnValue(null) + mocks.migrateRestoredEditorFileOwner.mockReset() + mocks.migrateRestoredEditorFileOwner.mockResolvedValue({ ok: true, fileId: 'owned-file' }) + mocks.getState.mockReset() + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + openFiles: [], + setLastKnownDiskSignature: vi.fn() + }) + container = document.body.appendChild(document.createElement('div')) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + }) + + it('keeps a client-local live-tail log on the client', async () => { + const logPath = '/Users/me/.codex/sessions/session.jsonl' + const activeFile = createOpenFile({ + id: logPath, + filePath: logPath, + relativePath: logPath, + worktreeId: 'repo-runtime::/work/demo-project', + readOnly: true, + liveTail: true + }) + mocks.readRuntimeFileContent.mockResolvedValue({ content: 'log line', isBinary: false }) + + await act(async () => root?.render()) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('log line')) + expect(mocks.findWorkspaceFileRoute).not.toHaveBeenCalled() + expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: logPath }) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: undefined, includeLocalLogMetadata: true }) + ) + }) + + it('atomically reparents an unstamped sibling file before reading', async () => { + const activeFile = createOpenFile({ + id: '/work/repo-b/docs/readme.md', + filePath: '/work/repo-b/docs/readme.md', + relativePath: '/work/repo-b/docs/readme.md', + worktreeId: 'repo-a::/work/repo-a' + }) + const route = { + worktreeId: 'repo-b::/work/repo-b', + relativePath: 'docs/readme.md', + executionHostId: 'runtime:runtime-1' + } as const + mocks.findWorkspaceFileRoute.mockReturnValue(route) + + await act(async () => root?.render()) + + await vi.waitFor(() => + expect(mocks.migrateRestoredEditorFileOwner).toHaveBeenCalledWith( + activeFile.id, + route, + 'runtime-1' + ) + ) + expect(authorizeExternalPath).not.toHaveBeenCalled() + expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts index 1e5f5e45e35..b2367e33721 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts @@ -73,6 +73,8 @@ describe('getEditorExternalWatchTargets', () => { rightSidebarExplorerView: args.rightSidebarExplorerView ?? 'files', gitStatusHugeByWorktree: args.gitStatusHugeByWorktree ?? {}, sshConnectionStates: args.sshConnectionStates ?? new Map(), + folderWorkspaces: [], + projectGroups: [], settings: args.runtimeEnvironmentId === undefined ? null diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 6ac41b3b6be..1b4ff62a44d 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -32,6 +32,8 @@ import { import { isGitRepoKind } from '../../../shared/repo-kind' import { markFileChangedOnDisk } from '@/components/editor/editor-changed-on-disk-mark' import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' // Why: atomic writes burst same-path events; one reload dispatch each fans out into N EditorPanel rebuilds that can wedge the renderer (issue #826), so debounce per (worktreeId+path). const EXTERNAL_RELOAD_DEBOUNCE_MS = 75 @@ -95,6 +97,8 @@ export type EditorExternalWatchTargetState = Pick< | 'rightSidebarExplorerView' | 'gitStatusHugeByWorktree' | 'sshConnectionStates' + | 'folderWorkspaces' + | 'projectGroups' > let cachedOpenFiles: AppState['openFiles'] | null = null @@ -107,6 +111,8 @@ let cachedRightSidebarTab: AppState['rightSidebarTab'] | null = null let cachedRightSidebarExplorerView: AppState['rightSidebarExplorerView'] | null = null let cachedGitStatusHugeByWorktree: AppState['gitStatusHugeByWorktree'] | null = null let cachedSshConnectionStates: AppState['sshConnectionStates'] | null = null +let cachedFolderWorkspaces: AppState['folderWorkspaces'] | null = null +let cachedProjectGroups: AppState['projectGroups'] | null = null let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = { targets: [], targetsKey: '' } export function getWatchedTargetKey(target: WatchedTarget): string { @@ -132,7 +138,9 @@ export function getEditorExternalWatchTargets( cachedRightSidebarTab === state.rightSidebarTab && cachedRightSidebarExplorerView === state.rightSidebarExplorerView && cachedGitStatusHugeByWorktree === state.gitStatusHugeByWorktree && - cachedSshConnectionStates === state.sshConnectionStates + cachedSshConnectionStates === state.sshConnectionStates && + cachedFolderWorkspaces === state.folderWorkspaces && + cachedProjectGroups === state.projectGroups ) { return cachedWatchedTargetsSnapshot } @@ -183,18 +191,31 @@ export function getEditorExternalWatchTargets( const sortedWorktreeIds = Array.from(targetOwnersByWorktreeId.keys()).sort() for (const id of sortedWorktreeIds) { const wt = findWorktreeById(state.worktreesByRepo, id) - if (!wt) { + const workspaceScope = parseWorkspaceKey(id) + const folderWorkspace = + workspaceScope?.type === 'folder' + ? state.folderWorkspaces.find( + (workspace) => workspace.id === workspaceScope.folderWorkspaceId + ) + : undefined + if (!wt && !folderWorkspace) { + continue + } + const repo = wt ? state.repos.find((r) => r.id === wt.repoId) : undefined + const connectionId = folderWorkspace + ? getFolderWorkspaceConnectionId(state, folderWorkspace.id) + : repo?.connectionId + if (connectionId === undefined && folderWorkspace) { continue } - const repo = state.repos.find((r) => r.id === wt.repoId) const owners = Array.from(targetOwnersByWorktreeId.get(id) ?? []).sort((a, b) => (a ?? '').localeCompare(b ?? '') ) for (const owner of owners) { const target = { worktreeId: id, - worktreePath: wt.path, - connectionId: repo?.connectionId ?? undefined, + worktreePath: wt?.path ?? folderWorkspace!.folderPath, + connectionId: connectionId ?? undefined, runtimeEnvironmentId: owner } nextTargets.push(target) @@ -213,6 +234,8 @@ export function getEditorExternalWatchTargets( cachedRightSidebarExplorerView = state.rightSidebarExplorerView cachedGitStatusHugeByWorktree = state.gitStatusHugeByWorktree cachedSshConnectionStates = state.sshConnectionStates + cachedFolderWorkspaces = state.folderWorkspaces + cachedProjectGroups = state.projectGroups if (targetsKey === cachedWatchedTargetsSnapshot.targetsKey) { return cachedWatchedTargetsSnapshot diff --git a/src/renderer/src/lib/runtime-workspace-file-route.test.ts b/src/renderer/src/lib/runtime-workspace-file-route.test.ts new file mode 100644 index 00000000000..74f36c81cbb --- /dev/null +++ b/src/renderer/src/lib/runtime-workspace-file-route.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { + findRuntimeWorkspaceFileRoute, + findWorkspaceFileRoute +} from './runtime-workspace-file-route' + +function state(): AppState { + return { + settings: { activeRuntimeEnvironmentId: 'runtime-a' }, + repos: [], + detectedWorktreesByRepo: {}, + worktreesByRepo: { + repoA: [ + { + id: 'repo-a::/srv/repo-a', + repoId: 'repo-a', + path: '/srv/repo-a', + runtimeOwnerEnvironmentId: 'runtime-a' + } + ], + repoB: [ + { + id: 'repo-b::/srv/repo-b', + repoId: 'repo-b', + path: '/srv/repo-b', + runtimeOwnerEnvironmentId: 'runtime-a' + }, + { + id: 'repo-b::/srv/repo-b/docs', + repoId: 'repo-b', + path: '/srv/repo-b/docs', + runtimeOwnerEnvironmentId: 'runtime-b' + } + ] + }, + folderWorkspaces: [ + { + id: 'notes', + projectGroupId: 'group-a', + folderPath: '/srv/notes', + connectionId: null + } + ], + projectGroups: [ + { + id: 'group-a', + connectionId: null, + executionHostId: 'runtime:runtime-a' + } + ] + } as unknown as AppState +} + +describe('findRuntimeWorkspaceFileRoute', () => { + it('routes a file through its sibling worktree on the same runtime', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/repo-b/src/index.ts')).toEqual( + { + worktreeId: 'repo-b::/srv/repo-b', + relativePath: 'src/index.ts', + executionHostId: 'runtime:runtime-a' + } + ) + }) + + it('does not use a more specific workspace owned by another runtime', () => { + expect( + findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/repo-b/docs/guide.md') + ).toEqual({ + worktreeId: 'repo-b::/srv/repo-b', + relativePath: 'docs/guide.md', + executionHostId: 'runtime:runtime-a' + }) + }) + + it('includes folder workspaces owned by the runtime', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/notes/todo.md')).toEqual({ + worktreeId: 'folder:notes', + relativePath: 'todo.md', + executionHostId: 'runtime:runtime-a' + }) + }) + + it('rejects paths outside every workspace owned by the runtime', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/etc/passwd')).toBeNull() + }) + + it('does not route workspace directories as editor files', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/repo-b')).toBeNull() + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/notes')).toBeNull() + }) + + it('routes direct SSH siblings only on the exact SSH host', () => { + const sshState = state() + sshState.repos = [ + { id: 'repo-a', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }, + { id: 'repo-b', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }, + { id: 'repo-c', connectionId: 'ssh-2', executionHostId: 'ssh:ssh-2' } + ] as never + sshState.worktreesByRepo = { + repoA: [{ id: 'a', repoId: 'repo-a', path: '/srv/a', hostId: 'ssh:ssh-1' }], + repoB: [{ id: 'b', repoId: 'repo-b', path: '/srv/b', hostId: 'ssh:ssh-1' }], + repoC: [{ id: 'c', repoId: 'repo-c', path: '/srv/b/nested', hostId: 'ssh:ssh-2' }] + } as never + sshState.folderWorkspaces = [] + + expect(findWorkspaceFileRoute(sshState, 'ssh:ssh-1', '/srv/b/nested/file.ts')).toEqual({ + worktreeId: 'b', + relativePath: 'nested/file.ts', + executionHostId: 'ssh:ssh-1' + }) + expect(findWorkspaceFileRoute(sshState, 'ssh:ssh-2', '/srv/a/file.ts')).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/runtime-workspace-file-route.ts b/src/renderer/src/lib/runtime-workspace-file-route.ts new file mode 100644 index 00000000000..af30b70fefd --- /dev/null +++ b/src/renderer/src/lib/runtime-workspace-file-route.ts @@ -0,0 +1,76 @@ +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { findRuntimeWorkspaceFileOwner } from '../../../shared/runtime-workspace-file-owner' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import type { AppState } from '@/store/types' +import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' +import { + getExecutionHostIdForWorktree, + getExplicitRuntimeEnvironmentIdForWorktree +} from './worktree-runtime-owner' +import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-route' + +export type RuntimeWorkspaceFileRoute = { + worktreeId: string + relativePath: string + executionHostId: ExecutionHostId +} + +function workspaceMatchesExecutionHost( + state: AppState, + workspaceId: string, + executionHostId: ExecutionHostId +): boolean { + const parsedHost = parseExecutionHostId(executionHostId) + if (parsedHost?.kind === 'runtime') { + return ( + getExplicitRuntimeEnvironmentIdForWorktree(state, workspaceId) === parsedHost.environmentId + ) + } + const explicit = resolveExplicitWorktreeOperationRouteResult(state, workspaceId) + if (explicit.kind === 'resolved') { + return explicit.route.executionHostId === executionHostId + } + return ( + executionHostId === 'local' && getExecutionHostIdForWorktree(state, workspaceId) === 'local' + ) +} + +export function findWorkspaceFileRoute( + state: AppState, + executionHostId: ExecutionHostId, + absolutePath: string +): RuntimeWorkspaceFileRoute | null { + const roots = getIndexedAllWorktrees(state.worktreesByRepo).flatMap((worktree) => + workspaceMatchesExecutionHost(state, worktree.id, executionHostId) + ? [{ workspaceId: worktree.id, rootPath: worktree.path, executionHostId }] + : [] + ) + for (const workspace of state.folderWorkspaces) { + const workspaceId = folderWorkspaceKey(workspace.id) + if (workspaceMatchesExecutionHost(state, workspaceId, executionHostId)) { + roots.push({ workspaceId, rootPath: workspace.folderPath, executionHostId }) + } + } + + const owner = findRuntimeWorkspaceFileOwner(roots, absolutePath, executionHostId) + return owner && owner.relativePath !== '' + ? { worktreeId: owner.workspaceId, relativePath: owner.relativePath, executionHostId } + : null +} + +export function findRuntimeWorkspaceFileRoute( + state: AppState, + runtimeEnvironmentId: string, + absolutePath: string +): RuntimeWorkspaceFileRoute | null { + const ownerId = runtimeEnvironmentId.trim() + if (!ownerId) { + return null + } + const executionHostId = toRuntimeExecutionHostId(ownerId) + return findWorkspaceFileRoute(state, executionHostId, absolutePath) +} diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index c3c2d630710..ace5cace0fe 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -49,6 +49,7 @@ import { COMBINED_DIFF_FILE_TREE_DEFAULT_WIDTH } from '../../../../shared/combined-diff-file-tree-width' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action' import { invalidateAutomaticPushTargetUpstreamStatusCache } from '@/components/right-sidebar/push-target-upstream-refresh-cache' import { @@ -72,7 +73,11 @@ import { } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab' -import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' +import { + findWorktreeById, + getRepoIdFromWorktreeId, + type ActiveWorktreeStateTransition +} from './worktree-helpers' import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { addAdditionalValidWorkspaceKeys, @@ -89,6 +94,9 @@ import { captureEditorFileOperationProvenance, getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { pruneTabGroupLayoutForGroups } from './tabs-hydration' +import { sanitizeRecentTabIds } from './tab-group-state' export type { ActiveRightSidebarTab, @@ -257,6 +265,8 @@ export type OpenFile = { pendingDiskBaselineVerification?: boolean /** Why: gates autosave during a live self-move echo's disk verification; separate flag from the restored scan's so the two can't clear each other's gate. Not persisted. */ pendingLiveDiskVerification?: boolean + /** Blocks saves while a restored tab's filesystem authority is being replaced. */ + pendingOwnerMigration?: boolean /** Why: routes an Orca-owned move's destination-watcher echo into content verification. On the tab so it survives the atomic rekey; operationId supersedes a stale verification on re-move. Not persisted. */ pendingSelfMoveEcho?: { operationId: string; targetPath: string } /** Why: diff bodies are cached in EditorPanel; bump this on re-select so the panel refetches instead of reusing a stale snapshot. */ @@ -521,6 +531,8 @@ export type EditorSlice = { /** When set, dirty autosave-capable destinations get move-echo provenance + a synchronous autosave gate so the watcher can content-verify the echo. */ moveOperationId?: string }) => RekeyOpenFilesResult + setRestoredEditorOwnerMigrationPending: (fileId: string, pending: boolean) => boolean + reparentRestoredEditorFileOwner: (args: RestoredEditorOwnerMigration) => RestoredEditorOwnerResult clearUntitled: (fileId: string) => void openDiff: ( worktreeId: string, @@ -1306,6 +1318,19 @@ export type OpenFilePathRekey = { export type RekeyOpenFilesResult = { ok: true } | { ok: false; reason: 'collision' | 'stale' } +export type RestoredEditorOwnerMigration = { + fileId: string + targetWorktreeId: string + targetRelativePath: string + targetExecutionHostId: ExecutionHostId + targetRuntimeEnvironmentId: string | null + targetOperationProvenance: EditorFileOperationProvenance +} + +export type RestoredEditorOwnerResult = + | { ok: true; fileId: string } + | { ok: false; reason: 'collision' | 'owner-changed' | 'stale' } + function rekeyFileIdRecord( record: Record, migrations: ReadonlyMap @@ -1324,6 +1349,41 @@ function rekeyFileIdRecord( return changed ? next : record } +function nextActiveIdAfterRemoval( + ids: readonly string[], + recentIds: readonly string[] | undefined, + removedIds: ReadonlySet +): string | null { + const recent = (recentIds ?? []).toReversed().find((id) => !removedIds.has(id)) + return recent ?? ids.find((id) => !removedIds.has(id)) ?? null +} + +function removeEmptyEditorGroups( + previousGroups: TabGroup[], + groups: TabGroup[], + movedTabIds: ReadonlySet, + layout: AppState['layoutByWorktree'][string] | undefined +): { groups: TabGroup[]; layout: AppState['layoutByWorktree'][string] | undefined } { + const emptiedGroupIds = new Set( + previousGroups + .filter( + (group) => + group.tabOrder.some((id) => movedTabIds.has(id)) && + groups.find((candidate) => candidate.id === group.id)?.tabOrder.length === 0 + ) + .map((group) => group.id) + ) + const remaining = groups.filter((group) => !emptiedGroupIds.has(group.id)) + if (remaining.length === 0) { + return { groups: [], layout: undefined } + } + const validIds = new Set(remaining.map((group) => group.id)) + return { + groups: remaining, + layout: layout ? (pruneTabGroupLayoutForGroups(layout, validIds) ?? undefined) : undefined + } +} + function deleteUntouchedUntitledFile(state: AppState, file: OpenFile): void { const worktree = findWorktreeById(state.worktreesByRepo, file.worktreeId) const owningRuntimeEnvironmentId = file.runtimeEnvironmentId?.trim() @@ -2635,6 +2695,327 @@ export const createEditorSlice: StateCreator = (s } }), + setRestoredEditorOwnerMigrationPending: (fileId, pending) => { + let changed = false + set((s) => { + const file = s.openFiles.find((candidate) => candidate.id === fileId) + const next = pending || undefined + if (!file || file.pendingOwnerMigration === next) { + return s + } + changed = true + return { + openFiles: s.openFiles.map((candidate) => + candidate.id === fileId ? { ...candidate, pendingOwnerMigration: next } : candidate + ) + } + }) + return changed + }, + + reparentRestoredEditorFileOwner: (args) => { + let result: RestoredEditorOwnerResult = { ok: false, reason: 'stale' } + const projectReparent: ActiveWorktreeStateTransition = (s) => { + const source = s.openFiles.find((file) => file.id === args.fileId) + if (!source) { + return { patch: {}, activate: false } + } + + try { + const currentRoute = assertEditorFileOperationCurrent( + s, + args.targetWorktreeId, + args.targetOperationProvenance + ) + if ( + currentRoute.executionHostId !== args.targetExecutionHostId || + currentRoute.runtimeEnvironmentId !== args.targetRuntimeEnvironmentId + ) { + throw new Error('owner changed') + } + } catch { + result = { ok: false, reason: 'owner-changed' } + return { + patch: { + openFiles: s.openFiles.map((file) => + file.id === args.fileId ? { ...file, pendingOwnerMigration: undefined } : file + ) + }, + activate: false + } + } + const operationProvenance = args.targetOperationProvenance + + const destinationCollision = s.openFiles.some( + (file) => + file.id !== source.id && + file.filePath === source.filePath && + file.worktreeId === args.targetWorktreeId && + (file.runtimeEnvironmentId?.trim() || null) === args.targetRuntimeEnvironmentId + ) + const newFileId = buildOwnedEditorFileId( + source.filePath, + args.targetWorktreeId, + args.targetRuntimeEnvironmentId + ) + const dependentPreviews = s.openFiles.filter( + (file) => file.markdownPreviewSourceFileId === source.id + ) + const previewIdMigrations = new Map( + dependentPreviews.map((preview) => [preview.id, `markdown-preview::${newFileId}`]) + ) + const destinationIds = new Set([newFileId, ...previewIdMigrations.values()]) + if ( + destinationCollision || + s.openFiles.some( + (file) => + destinationIds.has(file.id) && + file.id !== source.id && + !previewIdMigrations.has(file.id) + ) + ) { + result = { ok: false, reason: 'collision' } + return { + patch: { + openFiles: s.openFiles.map((file) => + file.id === args.fileId ? { ...file, pendingOwnerMigration: undefined } : file + ) + }, + activate: false + } + } + + const parsedHost = parseExecutionHostId(args.targetExecutionHostId) + const externalSshTargetId = + parsedHost?.kind === 'ssh' && args.targetRuntimeEnvironmentId === null + ? parsedHost.targetId + : undefined + const migrations = new Map([[source.id, newFileId], ...previewIdMigrations]) + const movedFileIds = new Set(migrations.keys()) + const sourceWorktreeId = source.worktreeId + const targetWorktreeId = args.targetWorktreeId + const movedTabs = (s.unifiedTabsByWorktree[sourceWorktreeId] ?? []).filter((tab) => + movedFileIds.has(tab.entityId) + ) + const movedTabIds = new Set(movedTabs.map((tab) => tab.id)) + const tabIdMigration = new Map( + movedTabs.map((tab) => [tab.id, migrations.get(tab.id) ?? tab.id]) + ) + const mappedMovedTabIds = movedTabs.map((tab) => tabIdMigration.get(tab.id) ?? tab.id) + const mappedMovedTabBarIds = movedTabs.map( + (tab) => migrations.get(tab.entityId) ?? tab.entityId + ) + const targetGroups = s.groupsByWorktree[targetWorktreeId] ?? [] + const targetGroupId = + s.activeGroupIdByWorktree[targetWorktreeId] ?? targetGroups[0]?.id ?? createBrowserUuid() + const targetGroup = targetGroups.find((group) => group.id === targetGroupId) ?? { + id: targetGroupId, + worktreeId: targetWorktreeId, + activeTabId: null, + tabOrder: [] + } + + const previousSourceGroups = s.groupsByWorktree[sourceWorktreeId] ?? [] + const updatedSourceGroups = previousSourceGroups.map((group) => { + const tabOrder = group.tabOrder.filter((id) => !movedTabIds.has(id)) + const activeTabId = + group.activeTabId && movedTabIds.has(group.activeTabId) + ? nextActiveIdAfterRemoval(group.tabOrder, group.recentTabIds, movedTabIds) + : group.activeTabId + return { + ...group, + activeTabId, + tabOrder, + recentTabIds: sanitizeRecentTabIds( + (group.recentTabIds ?? []).filter((id) => !movedTabIds.has(id)), + tabOrder + ) + } + }) + const sourceGroupState = removeEmptyEditorGroups( + previousSourceGroups, + updatedSourceGroups, + movedTabIds, + s.layoutByWorktree[sourceWorktreeId] + ) + const destinationOrder = [ + ...targetGroup.tabOrder.filter((id) => !mappedMovedTabIds.includes(id)), + ...mappedMovedTabIds + ] + const updatedTargetGroup: TabGroup = { + ...targetGroup, + activeTabId: mappedMovedTabIds.at(-1) ?? targetGroup.activeTabId, + tabOrder: destinationOrder, + recentTabIds: sanitizeRecentTabIds( + [...(targetGroup.recentTabIds ?? []), ...mappedMovedTabIds], + destinationOrder + ) + } + const nextTargetGroups = targetGroups.some((group) => group.id === targetGroupId) + ? targetGroups.map((group) => (group.id === targetGroupId ? updatedTargetGroup : group)) + : [...targetGroups, updatedTargetGroup] + + const nextUnifiedTabsByWorktree = { ...s.unifiedTabsByWorktree } + nextUnifiedTabsByWorktree[sourceWorktreeId] = ( + nextUnifiedTabsByWorktree[sourceWorktreeId] ?? [] + ).filter((tab) => !movedTabIds.has(tab.id)) + nextUnifiedTabsByWorktree[targetWorktreeId] = [ + ...(nextUnifiedTabsByWorktree[targetWorktreeId] ?? []), + ...movedTabs.map((tab) => ({ + ...tab, + id: tabIdMigration.get(tab.id) ?? tab.id, + entityId: migrations.get(tab.entityId) ?? tab.entityId, + groupId: targetGroupId + })) + ] + + const nextGroupsByWorktree = { + ...s.groupsByWorktree, + [sourceWorktreeId]: sourceGroupState.groups, + [targetWorktreeId]: nextTargetGroups + } + const nextLayoutByWorktree = { ...s.layoutByWorktree } + if (sourceGroupState.layout) { + nextLayoutByWorktree[sourceWorktreeId] = sourceGroupState.layout + } else { + delete nextLayoutByWorktree[sourceWorktreeId] + } + if (targetGroups.length === 0 || !nextLayoutByWorktree[targetWorktreeId]) { + nextLayoutByWorktree[targetWorktreeId] = { type: 'leaf', groupId: targetGroupId } + } + + const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree } + const sourceActiveFileId = nextActiveFileIdByWorktree[sourceWorktreeId] + if (sourceActiveFileId && movedFileIds.has(sourceActiveFileId)) { + nextActiveFileIdByWorktree[sourceWorktreeId] = + s.openFiles.find( + (file) => !movedFileIds.has(file.id) && file.worktreeId === sourceWorktreeId + )?.id ?? null + } + nextActiveFileIdByWorktree[targetWorktreeId] = newFileId + const nextTabBarOrderByWorktree = { ...s.tabBarOrderByWorktree } + nextTabBarOrderByWorktree[sourceWorktreeId] = ( + nextTabBarOrderByWorktree[sourceWorktreeId] ?? [] + ).filter((id) => !movedFileIds.has(id) && !movedTabIds.has(id)) + nextTabBarOrderByWorktree[targetWorktreeId] = [ + ...(nextTabBarOrderByWorktree[targetWorktreeId] ?? []).filter( + (id) => !mappedMovedTabBarIds.includes(id) + ), + ...mappedMovedTabBarIds + ] + const nextActiveGroupIdByWorktree = { ...s.activeGroupIdByWorktree } + if (sourceGroupState.groups.length > 0) { + const previousActiveGroupId = nextActiveGroupIdByWorktree[sourceWorktreeId] + nextActiveGroupIdByWorktree[sourceWorktreeId] = sourceGroupState.groups.some( + (group) => group.id === previousActiveGroupId + ) + ? previousActiveGroupId + : sourceGroupState.groups[0].id + } else { + delete nextActiveGroupIdByWorktree[sourceWorktreeId] + } + nextActiveGroupIdByWorktree[targetWorktreeId] = targetGroupId + + result = { ok: true, fileId: newFileId } + return { + patch: { + openFiles: s.openFiles.map((file) => + file.id === source.id + ? { + ...file, + id: newFileId, + worktreeId: targetWorktreeId, + relativePath: args.targetRelativePath, + runtimeEnvironmentId: args.targetRuntimeEnvironmentId, + externalSshTargetId, + operationProvenance, + pendingOwnerMigration: undefined, + mirroredFromRuntimeSession: undefined + } + : file.markdownPreviewSourceFileId === source.id + ? { + ...file, + id: previewIdMigrations.get(file.id) ?? file.id, + worktreeId: targetWorktreeId, + relativePath: args.targetRelativePath, + runtimeEnvironmentId: args.targetRuntimeEnvironmentId, + externalSshTargetId, + operationProvenance, + markdownPreviewSourceFileId: newFileId, + pendingOwnerMigration: undefined, + mirroredFromRuntimeSession: undefined + } + : file + ), + editorDrafts: rekeyFileIdRecord(s.editorDrafts, migrations), + editorCursorLine: rekeyFileIdRecord(s.editorCursorLine, migrations), + markdownViewMode: rekeyFileIdRecord(s.markdownViewMode, migrations), + editorViewMode: rekeyFileIdRecord(s.editorViewMode, migrations), + markdownFrontmatterVisible: rekeyFileIdRecord(s.markdownFrontmatterVisible, migrations), + markdownTableOfContentsVisible: rekeyFileIdRecord( + s.markdownTableOfContentsVisible, + migrations + ), + activeFileId: s.activeFileId ? (migrations.get(s.activeFileId) ?? s.activeFileId) : null, + activeFileIdByWorktree: nextActiveFileIdByWorktree, + activeTabTypeByWorktree: { + ...s.activeTabTypeByWorktree, + [targetWorktreeId]: 'editor' + }, + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + tabBarOrderByWorktree: nextTabBarOrderByWorktree, + ...(s.pendingEditorReveal?.fileId && migrations.has(s.pendingEditorReveal.fileId) + ? { + pendingEditorReveal: { + ...s.pendingEditorReveal, + fileId: migrations.get(s.pendingEditorReveal.fileId)!, + filePath: source.filePath + } + } + : {}), + ...(s.pendingEditorFocusRequest?.fileId && + migrations.has(s.pendingEditorFocusRequest.fileId) + ? { + pendingEditorFocusRequest: { + ...s.pendingEditorFocusRequest, + fileId: migrations.get(s.pendingEditorFocusRequest.fileId)!, + worktreeId: targetWorktreeId + } + } + : {}), + ...(s.pendingExplorerReveal?.filePath === source.filePath && + s.pendingExplorerReveal.worktreeId === sourceWorktreeId + ? { + pendingExplorerReveal: { + ...s.pendingExplorerReveal, + worktreeId: targetWorktreeId + } + } + : {}) + }, + activate: true, + preferredActiveUnifiedTabId: mappedMovedTabIds.at(-1) + } + } + const initialSource = get().openFiles.find((file) => file.id === args.fileId) + const activatesTargetWorkspace = Boolean( + initialSource && + get().activeWorktreeId === initialSource.worktreeId && + get().activeFileId === initialSource.id + ) + if (activatesTargetWorkspace) { + get().setActiveWorktree(args.targetWorktreeId, args.targetExecutionHostId, { + stateTransition: projectReparent + }) + } else { + set((state) => projectReparent(state).patch) + } + return result + }, + rekeyOpenFilesForPathChange: ({ rekeys, moveOperationId }) => { if (rekeys.length === 0) { return { ok: true } diff --git a/src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts b/src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts new file mode 100644 index 00000000000..d3e8f37f15f --- /dev/null +++ b/src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts @@ -0,0 +1,683 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { useAppStore } from '@/store' +import { buildEditorSessionData } from '@/lib/workspace-session' +import { + createExternalWatchEventHandler, + getEditorExternalWatchTargets, + useEditorExternalWatch +} from '@/hooks/useEditorExternalWatch' +import { + captureEditorFileOperationProvenance, + getEditorFileOperationContext +} from '@/lib/editor-file-operation-owner' +import type { AppState } from '@/store/types' +import type { WorkspaceSessionState } from '../../../../shared/types' + +const SOURCE = 'repo-a::/repo-a' +const TARGET = 'repo-b::/repo-b' +const FILE_PATH = '/repo-b/docs/readme.md' + +function WatchProbe(): null { + useEditorExternalWatch() + return null +} + +function installWorkspaceState(): void { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + activeWorktreeId: SOURCE, + settings: { editorAutoSave: false }, + repos: [ + { id: 'repo-a', path: '/repo-a', kind: 'git', executionHostId: 'local' }, + { id: 'repo-b', path: '/repo-b', kind: 'git', executionHostId: 'local' } + ], + worktreesByRepo: { + 'repo-a': [{ id: SOURCE, repoId: 'repo-a', path: '/repo-a', hostId: 'local', branch: '' }], + 'repo-b': [{ id: TARGET, repoId: 'repo-b', path: '/repo-b', hostId: 'local', branch: '' }] + }, + detectedWorktreesByRepo: {}, + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: true, + removedRuntimeEnvironmentIds: new Set(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + folderWorkspaces: [], + projectGroups: [], + rightSidebarOpen: false, + rightSidebarTab: 'explorer', + rightSidebarExplorerView: 'files', + gitStatusHugeByWorktree: {} + } as unknown as Partial) +} + +function openRestoredSource(): string { + return useAppStore.getState().openFile( + { + filePath: FILE_PATH, + relativePath: FILE_PATH, + worktreeId: SOURCE, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit', + pendingDiskBaselineVerification: true + }, + { suppressActiveRuntimeFallback: true } + ) +} + +function reparent(fileId: string) { + const state = useAppStore.getState() + state.setRestoredEditorOwnerMigrationPending(fileId, true) + return state.reparentRestoredEditorFileOwner({ + fileId, + targetWorktreeId: TARGET, + targetRelativePath: 'docs/readme.md', + targetExecutionHostId: 'local', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance(state, TARGET, null, true) + }) +} + +describe('restored editor owner reparent', () => { + beforeEach(() => { + vi.restoreAllMocks() + installWorkspaceState() + }) + + afterEach(() => vi.useRealTimers()) + + it('atomically migrates ownership, ids, placement, views, previews, and provenance', () => { + const oldId = openRestoredSource() + const state = useAppStore.getState() + state.setEditorDraft(oldId, 'edited draft') + state.markFileDirty(oldId, true) + state.setEditorCursorLine(oldId, 37) + state.setMarkdownViewMode(oldId, 'rich') + state.setEditorViewMode(oldId, 'changes') + state.setMarkdownFrontmatterVisible(oldId, false) + state.setMarkdownTableOfContentsVisible(oldId, true) + useAppStore.setState((current) => ({ + pendingEditorReveal: { + fileId: oldId, + filePath: FILE_PATH, + line: 4, + column: 2, + matchLength: 0 + }, + pendingEditorFocusRequest: { + fileId: oldId, + worktreeId: SOURCE, + viewStateId: 'view-1', + expiresAt: Date.now() + 1_000, + token: 1 + }, + pendingExplorerReveal: { worktreeId: SOURCE, filePath: FILE_PATH, requestId: 1 }, + openFiles: [ + ...current.openFiles, + { + ...current.openFiles[0], + id: 'preview-1', + mode: 'markdown-preview' as const, + markdownPreviewSourceFileId: oldId, + isDirty: false + } + ] + })) + + const result = reparent(oldId) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const next = useAppStore.getState() + const moved = next.openFiles.find((file) => file.id === result.fileId)! + expect(moved).toMatchObject({ + filePath: FILE_PATH, + relativePath: 'docs/readme.md', + worktreeId: TARGET, + runtimeEnvironmentId: null, + isDirty: true, + pendingDiskBaselineVerification: true + }) + expect(moved.pendingOwnerMigration).toBeUndefined() + expect(moved.operationProvenance?.generation.route).toEqual({ + executionHostId: 'local', + runtimeEnvironmentId: null + }) + expect(getEditorFileOperationContext(next, moved, '/repo-b').worktreeId).toBe(TARGET) + expect(next.editorDrafts).toEqual({ [result.fileId]: 'edited draft' }) + expect(next.editorCursorLine[result.fileId]).toBe(37) + expect(next.markdownViewMode[result.fileId]).toBe('rich') + expect(next.editorViewMode[result.fileId]).toBe('changes') + expect(next.markdownFrontmatterVisible[result.fileId]).toBe(false) + expect(next.markdownTableOfContentsVisible[result.fileId]).toBe(true) + expect(next.openFiles.find((file) => file.mode === 'markdown-preview')).toMatchObject({ + id: `markdown-preview::${result.fileId}`, + markdownPreviewSourceFileId: result.fileId, + worktreeId: TARGET, + relativePath: 'docs/readme.md' + }) + expect(next.unifiedTabsByWorktree[SOURCE]).toEqual([]) + const movedTabId = next.unifiedTabsByWorktree[TARGET]?.[0]?.id + expect(next.unifiedTabsByWorktree[TARGET]?.[0]?.entityId).toBe(result.fileId) + expect(next.groupsByWorktree[TARGET]?.[0]).toMatchObject({ + activeTabId: movedTabId, + tabOrder: [movedTabId] + }) + expect(next.tabBarOrderByWorktree[TARGET]).toEqual([result.fileId]) + expect(next.activeFileIdByWorktree[SOURCE]).toBeNull() + expect(next.activeFileIdByWorktree[TARGET]).toBe(result.fileId) + expect(next.activeWorktreeId).toBe(TARGET) + expect(next.activeWorkspaceExecutionHostId).toBe('local') + expect(next.activeRepoId).toBe('repo-b') + expect(next.activeGroupIdByWorktree[SOURCE]).toBeUndefined() + expect(next.pendingEditorReveal?.fileId).toBe(result.fileId) + expect(next.pendingEditorFocusRequest).toMatchObject({ + fileId: result.fileId, + worktreeId: TARGET + }) + expect(next.pendingExplorerReveal?.worktreeId).toBe(TARGET) + }) + + it('does not retarget an explorer reveal owned by another workspace', () => { + const oldId = openRestoredSource() + useAppStore.setState({ + pendingExplorerReveal: { worktreeId: 'other-workspace', filePath: FILE_PATH, requestId: 1 } + }) + + expect(reparent(oldId).ok).toBe(true) + expect(useAppStore.getState().pendingExplorerReveal?.worktreeId).toBe('other-workspace') + }) + + it('commits active reparenting through the complete workspace activation projection', () => { + const oldId = openRestoredSource() + const refreshGitHubForWorktreeIfStale = vi.fn() + useAppStore.setState({ + activeTabId: 'source-terminal', + activeBrowserTabId: 'source-browser', + activePendingCreationId: 'source-creation', + rightSidebarExplorerView: 'files', + rightSidebarExplorerViewByWorktree: { [TARGET]: 'search' }, + tabsByWorktree: { + [SOURCE]: [ + { + id: 'source-terminal', + ptyId: null, + worktreeId: SOURCE, + title: 'Source terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + generation: 1 + } + ], + [TARGET]: [ + { + id: 'target-terminal', + ptyId: null, + worktreeId: TARGET, + title: 'Target terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + generation: 4 + } + ] + }, + activeTabIdByWorktree: { + [SOURCE]: 'source-terminal', + [TARGET]: 'target-terminal' + }, + unifiedTabsByWorktree: { + ...useAppStore.getState().unifiedTabsByWorktree, + [TARGET]: [ + { + id: 'target-terminal', + entityId: 'target-terminal', + groupId: 'target-group', + worktreeId: TARGET, + contentType: 'terminal', + label: 'Target terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + ...useAppStore.getState().groupsByWorktree, + [TARGET]: [ + { + id: 'target-group', + worktreeId: TARGET, + activeTabId: 'target-terminal', + tabOrder: ['target-terminal'] + } + ] + }, + layoutByWorktree: { + ...useAppStore.getState().layoutByWorktree, + [TARGET]: { type: 'leaf', groupId: 'target-group' } + }, + activeGroupIdByWorktree: { + ...useAppStore.getState().activeGroupIdByWorktree, + [TARGET]: 'target-group' + }, + browserTabsByWorktree: { + [SOURCE]: [{ id: 'source-browser' }], + [TARGET]: [{ id: 'target-browser' }] + }, + activeBrowserTabIdByWorktree: { + [SOURCE]: 'source-browser', + [TARGET]: 'target-browser' + }, + everActivatedWorktreeIds: new Set([SOURCE]), + refreshGitHubForWorktreeIfStale + } as unknown as Partial) + const targetSnapshots: AppState[] = [] + const unsubscribe = useAppStore.subscribe((state) => { + if (state.activeWorktreeId === TARGET) { + targetSnapshots.push(state) + } + }) + + const result = reparent(oldId) + unsubscribe() + + expect(result.ok).toBe(true) + expect(targetSnapshots).toHaveLength(1) + const activated = targetSnapshots[0] + expect(activated).toMatchObject({ + activeWorktreeId: TARGET, + activeRepoId: 'repo-b', + activeWorkspaceExecutionHostId: 'local', + activeTabId: 'target-terminal', + activeBrowserTabId: 'target-browser', + activePendingCreationId: null, + rightSidebarExplorerView: 'search', + activeTabType: 'editor' + }) + expect(activated.activeFileId).toBe(result.ok ? result.fileId : null) + expect(activated.everActivatedWorktreeIds).toEqual(new Set([SOURCE, TARGET])) + expect(activated.tabsByWorktree[TARGET]?.[0]).toMatchObject({ + id: 'target-terminal', + generation: 5 + }) + expect(refreshGitHubForWorktreeIfStale).toHaveBeenCalledOnce() + expect(refreshGitHubForWorktreeIfStale).toHaveBeenCalledWith(TARGET) + }) + + it('persists and restores the destination owner and dirty hot-exit draft', () => { + const oldId = openRestoredSource() + useAppStore.getState().setEditorDraft(oldId, 'hot exit') + useAppStore.getState().markFileDirty(oldId, true) + const result = reparent(oldId) + expect(result.ok).toBe(true) + + const state = useAppStore.getState() + const session = buildEditorSessionData( + state.openFiles, + state.editorDrafts, + state.markdownFrontmatterVisible, + state.activeFileIdByWorktree, + state.activeTabTypeByWorktree + ) + expect(session.openFilesByWorktree?.[SOURCE]).toBeUndefined() + expect(session.openFilesByWorktree?.[TARGET]?.[0]).toMatchObject({ + relativePath: 'docs/readme.md', + dirtyDraftContent: 'hot exit' + }) + + installWorkspaceState() + useAppStore.getState().hydrateEditorSession(session as WorkspaceSessionState) + const restored = useAppStore.getState().openFiles[0] + expect(restored).toMatchObject({ + worktreeId: TARGET, + relativePath: 'docs/readme.md', + isDirty: true + }) + expect(useAppStore.getState().editorDrafts[restored.id]).toBe('hot exit') + }) + + it.each([false, true])('fails closed on a %s destination collision', (dirtyDestination) => { + const sourceId = openRestoredSource() + const destinationId = useAppStore.getState().openFile( + { + filePath: FILE_PATH, + relativePath: 'docs/readme.md', + worktreeId: TARGET, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + if (dirtyDestination) { + useAppStore.getState().setEditorDraft(destinationId, 'destination draft') + useAppStore.getState().markFileDirty(destinationId, true) + } + const before = useAppStore.getState().openFiles + + expect(reparent(sourceId)).toEqual({ ok: false, reason: 'collision' }) + expect(useAppStore.getState().openFiles.map((file) => file.id)).toEqual( + before.map((file) => file.id) + ) + expect(useAppStore.getState().openFiles.find((file) => file.id === sourceId)?.worktreeId).toBe( + SOURCE + ) + }) + + it('leaves destination tabs and groups byte-identical when activation hits a collision', () => { + const sourceId = openRestoredSource() + const destinationId = useAppStore.getState().openFile( + { + filePath: FILE_PATH, + relativePath: 'docs/readme.md', + worktreeId: TARGET, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const destinationTab = useAppStore + .getState() + .unifiedTabsByWorktree[TARGET]?.find((tab) => tab.entityId === destinationId) + expect(destinationTab).toBeDefined() + const staleTab = { + ...destinationTab!, + id: 'stale-target-tab', + entityId: 'stale-target-file' + } + useAppStore.setState((state) => ({ + activeWorktreeId: SOURCE, + activeFileId: sourceId, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [TARGET]: [...(state.unifiedTabsByWorktree[TARGET] ?? []), staleTab] + }, + groupsByWorktree: { + ...state.groupsByWorktree, + [TARGET]: (state.groupsByWorktree[TARGET] ?? []).map((group) => + group.id === staleTab.groupId + ? { + ...group, + activeTabId: staleTab.id, + tabOrder: [...group.tabOrder, staleTab.id], + recentTabIds: [...(group.recentTabIds ?? []), staleTab.id] + } + : group + ) + } + })) + const state = useAppStore.getState() + state.setRestoredEditorOwnerMigrationPending(sourceId, true) + const before = useAppStore.getState() + + const result = before.reparentRestoredEditorFileOwner({ + fileId: sourceId, + targetWorktreeId: TARGET, + targetRelativePath: 'docs/readme.md', + targetExecutionHostId: 'local', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance(before, TARGET, null, true) + }) + + expect(result).toEqual({ ok: false, reason: 'collision' }) + const after = useAppStore.getState() + expect(after.unifiedTabsByWorktree).toBe(before.unifiedTabsByWorktree) + expect(after.groupsByWorktree).toBe(before.groupsByWorktree) + expect(after.layoutByWorktree).toBe(before.layoutByWorktree) + expect(after.activeGroupIdByWorktree).toBe(before.activeGroupIdByWorktree) + expect(after.activeWorktreeId).toBe(SOURCE) + expect(after.activeFileId).toBe(sourceId) + }) + + it('moves the watcher projection from source to destination without fanout', () => { + const oldId = openRestoredSource() + const before = getEditorExternalWatchTargets(useAppStore.getState()) + const result = reparent(oldId) + expect(result.ok).toBe(true) + const after = getEditorExternalWatchTargets(useAppStore.getState()) + + expect(before.targets.map((target) => target.worktreeId)).toEqual([SOURCE]) + expect(after.targets).toEqual([ + { + worktreeId: TARGET, + worktreePath: '/repo-b', + connectionId: undefined, + runtimeEnvironmentId: null + } + ]) + }) + + it('unsubscribes the source watch once and subscribes the destination once', async () => { + const watchWorktree = vi.fn().mockResolvedValue(undefined) + const unwatchWorktree = vi.fn().mockResolvedValue(undefined) + const previousApi = (window as unknown as { api?: unknown }).api + ;(window as unknown as { api: unknown }).api = { + fs: { + watchWorktree, + unwatchWorktree, + onFsChanged: vi.fn(() => vi.fn()) + } + } + const oldId = openRestoredSource() + const container = document.body.appendChild(document.createElement('div')) + const root = createRoot(container) + await act(async () => root.render(createElement(WatchProbe))) + await vi.waitFor(() => expect(watchWorktree).toHaveBeenCalledTimes(1)) + expect(watchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/repo-a', + connectionId: undefined + }) + + await act(async () => { + expect(reparent(oldId).ok).toBe(true) + }) + await vi.waitFor(() => { + expect(unwatchWorktree).toHaveBeenCalledTimes(1) + expect(watchWorktree).toHaveBeenCalledTimes(2) + }) + expect(unwatchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/repo-a', + connectionId: undefined + }) + expect(watchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/repo-b', + connectionId: undefined + }) + + await act(async () => root.unmount()) + container.remove() + ;(window as unknown as { api?: unknown }).api = previousApi + }) + + it('reparents into a folder workspace with local save and watch authority', () => { + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'notes', + projectGroupId: 'group-notes', + folderPath: '/notes', + connectionId: null, + executionHostId: 'local' + } + ], + projectGroups: [{ id: 'group-notes', name: 'Notes', executionHostId: 'local' }] + } as unknown as Partial) + const oldId = useAppStore.getState().openFile( + { + filePath: '/notes/todo.md', + relativePath: '/notes/todo.md', + worktreeId: SOURCE, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + useAppStore.getState().setRestoredEditorOwnerMigrationPending(oldId, true) + const result = useAppStore.getState().reparentRestoredEditorFileOwner({ + fileId: oldId, + targetWorktreeId: 'folder:notes', + targetRelativePath: 'todo.md', + targetExecutionHostId: 'local', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance( + useAppStore.getState(), + 'folder:notes', + null, + true + ) + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const state = useAppStore.getState() + const moved = state.openFiles.find((file) => file.id === result.fileId)! + expect(getEditorFileOperationContext(state, moved, null)).toMatchObject({ + worktreeId: 'folder:notes', + worktreePath: '/notes', + expectedExecutionHostId: 'local' + }) + expect(getEditorExternalWatchTargets(state).targets).toEqual([ + { + worktreeId: 'folder:notes', + worktreePath: '/notes', + connectionId: undefined, + runtimeEnvironmentId: null + } + ]) + }) + + it('captures exact direct-SSH host and connection-generation authority', () => { + useAppStore.setState((state) => ({ + repos: state.repos.map((repo) => ({ + ...repo, + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + })), + worktreesByRepo: Object.fromEntries( + Object.entries(state.worktreesByRepo).map(([repoId, worktrees]) => [ + repoId, + worktrees.map((worktree) => ({ ...worktree, hostId: 'ssh:ssh-1' as const })) + ]) + ), + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + connectionGeneration: 7 + } + ] + ]) + })) + const oldId = openRestoredSource() + useAppStore.getState().setRestoredEditorOwnerMigrationPending(oldId, true) + const result = useAppStore.getState().reparentRestoredEditorFileOwner({ + fileId: oldId, + targetWorktreeId: TARGET, + targetRelativePath: 'docs/readme.md', + targetExecutionHostId: 'ssh:ssh-1', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance( + useAppStore.getState(), + TARGET, + null, + true + ) + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect( + useAppStore.getState().openFiles.find((file) => file.id === result.fileId) + ).toMatchObject({ + externalSshTargetId: 'ssh-1', + operationProvenance: { + expectedSshConnectionGeneration: 7, + generation: { + route: { executionHostId: 'ssh:ssh-1', runtimeEnvironmentId: null } + } + } + }) + }) + + it('correlates destination change, delete, and rename events after reparent', () => { + vi.useFakeTimers() + const oldId = openRestoredSource() + const result = reparent(oldId) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const target = { + worktreeId: TARGET, + worktreePath: '/repo-b', + connectionId: undefined, + runtimeEnvironmentId: null + } + const externalChanges: unknown[] = [] + const listener = (event: Event): void => { + externalChanges.push((event as CustomEvent).detail) + } + window.addEventListener('orca:editor-external-file-change', listener) + const watcher = createExternalWatchEventHandler((path, owner) => + path === target.worktreePath && owner === null ? target : undefined + ) + + watcher.handleFsChanged({ + worktreePath: '/repo-b', + events: [{ kind: 'update', absolutePath: FILE_PATH }] + }) + vi.advanceTimersByTime(100) + expect(externalChanges).toContainEqual({ + worktreeId: TARGET, + worktreePath: '/repo-b', + relativePath: 'docs/readme.md', + runtimeEnvironmentId: null + }) + + watcher.handleFsChanged({ + worktreePath: '/repo-b', + events: [{ kind: 'delete', absolutePath: FILE_PATH }] + }) + vi.advanceTimersByTime(100) + expect( + useAppStore.getState().openFiles.find((file) => file.id === result.fileId)?.externalMutation + ).toBe('deleted') + useAppStore.getState().setExternalMutation(result.fileId, null) + + watcher.handleFsChanged({ + worktreePath: '/repo-b', + events: [ + { kind: 'delete', absolutePath: FILE_PATH }, + { kind: 'create', absolutePath: '/repo-b/archive/readme.md' } + ] + }) + expect( + useAppStore.getState().openFiles.find((file) => file.id === result.fileId)?.externalMutation + ).toBe('renamed') + + watcher.dispose() + window.removeEventListener('orca:editor-external-file-change', listener) + vi.useRealTimers() + }) +}) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 72e22adc136..5a2456c24c0 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -616,6 +616,215 @@ function activeSurfacePatchMatchesState( ) } +export type WorktreeTabModelReconciliation = { + patch: Partial + renderableTabCount: number + activeRenderableTabId: string | null +} + +export function projectWorktreeTabModelReconciliation( + state: AppState, + worktreeId: string +): WorktreeTabModelReconciliation { + const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const groups = state.groupsByWorktree[worktreeId] ?? [] + const runtimeTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const unifiedTerminalEntityIds = new Set( + unifiedTabs.filter((tab) => tab.contentType === 'terminal').map((tab) => tab.entityId) + ) + const legacyRuntimeTerminalTabs = runtimeTerminalTabs.filter((tab) => { + if (unifiedTerminalEntityIds.has(tab.id)) { + return false + } + // Why: reconnectable legacy tabs must re-enter the unified model instead of being orphaned. + return terminalTabHasReconnectablePty(state, tab.id, tab.ptyId) + }) + const orphanTerminalIds = getOrphanTerminalIds(state, worktreeId) + const ensuredGroupState = + legacyRuntimeTerminalTabs.length > 0 + ? ensureGroup( + state.groupsByWorktree, + state.activeGroupIdByWorktree, + worktreeId, + state.activeGroupIdByWorktree[worktreeId] + ) + : null + const reconciliationGroup = ensuredGroupState?.group ?? groups[0] ?? null + const restoredLegacyTabs = + reconciliationGroup == null + ? [] + : legacyRuntimeTerminalTabs + .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) + .map((tab) => ({ + id: tab.id, + entityId: tab.id, + groupId: reconciliationGroup.id, + worktreeId, + contentType: 'terminal' as const, + label: tab.title, + ...(tab.quickCommandLabel?.trim() + ? { quickCommandLabel: tab.quickCommandLabel.trim() } + : {}), + ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder: tab.sortOrder, + createdAt: tab.createdAt + })) + const reconciledUnifiedTabs = + restoredLegacyTabs.length > 0 ? [...unifiedTabs, ...restoredLegacyTabs] : unifiedTabs + const rememberedLegacyActiveTabId = state.activeTabIdByWorktree[worktreeId] + const restoredLegacyTabIds = new Set(restoredLegacyTabs.map((tab) => tab.id)) + const legacyFallbackActiveTabId = + rememberedLegacyActiveTabId && restoredLegacyTabIds.has(rememberedLegacyActiveTabId) + ? rememberedLegacyActiveTabId + : (restoredLegacyTabs[0]?.id ?? null) + const reconciledGroups = + restoredLegacyTabs.length > 0 && reconciliationGroup + ? updateGroup(ensuredGroupState!.groupsByWorktree[worktreeId] ?? [], { + ...reconciliationGroup, + // Why: restore runtime tabs into the active/root group so reattach cannot spawn a duplicate. + activeTabId: reconciliationGroup.activeTabId ?? legacyFallbackActiveTabId, + tabOrder: dedupeTabOrder([ + ...reconciliationGroup.tabOrder, + ...restoredLegacyTabs.map((tab) => tab.id) + ]) + }) + : groups + const liveTerminalIds = new Set( + runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) + ) + const liveEditorIds = new Set( + state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) + ) + const liveBrowserIds = new Set( + (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) + ) + + const isRenderableTab = (tab: Tab): boolean => { + if (tab.contentType === 'terminal') { + return liveTerminalIds.has(tab.entityId) + } + if (tab.contentType === 'browser') { + return liveBrowserIds.has(tab.entityId) + } + if (tab.contentType === 'simulator') { + return true + } + return liveEditorIds.has(tab.entityId) + } + + const validTabs = reconciledUnifiedTabs.filter(isRenderableTab) + const validTabIds = new Set(validTabs.map((tab) => tab.id)) + const nextGroupsWithEmpty = reconciledGroups.map((group) => { + const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) + const activeTabId = + group.activeTabId && validTabIds.has(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + const tabOrderUnchanged = + tabOrder.length === group.tabOrder.length && + tabOrder.every((tabId, index) => tabId === group.tabOrder[index]) + const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder) + const recentUnchanged = + recentTabIds.length === (group.recentTabIds ?? []).length && + recentTabIds.every((id, index) => id === (group.recentTabIds ?? [])[index]) + return tabOrderUnchanged && activeTabId === group.activeTabId && recentUnchanged + ? group + : { ...group, tabOrder, activeTabId, recentTabIds } + }) + const nextGroups = + validTabs.length > 0 + ? nextGroupsWithEmpty.filter((group) => group.tabOrder.length > 0) + : nextGroupsWithEmpty + const currentActiveGroupId = + state.activeGroupIdByWorktree[worktreeId] ?? + ensuredGroupState?.activeGroupIdByWorktree[worktreeId] + const activeGroupStillExists = nextGroups.some((group) => group.id === currentActiveGroupId) + const nextActiveGroupId = activeGroupStillExists + ? currentActiveGroupId + : (nextGroups.find((group) => group.activeTabId !== null)?.id ?? + nextGroups[0]?.id ?? + currentActiveGroupId) + const groupsChanged = + nextGroups.length !== groups.length || + nextGroups.some((group, index) => group !== groups[index]) + const tabsChanged = validTabs.length !== unifiedTabs.length || restoredLegacyTabs.length > 0 + const activeGroupChanged = nextActiveGroupId !== currentActiveGroupId + const baseNextLayout = + restoredLegacyTabs.length > 0 && reconciliationGroup + ? (state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: reconciliationGroup.id }) + : state.layoutByWorktree[worktreeId] + const validGroupIds = new Set(nextGroups.map((group) => group.id)) + const prunedNextLayout = + baseNextLayout && validGroupIds.size > 0 + ? pruneTabGroupLayoutForGroups(baseNextLayout, validGroupIds) + : baseNextLayout + const nextLayout = + prunedNextLayout ?? (nextGroups[0] ? { type: 'leaf', groupId: nextGroups[0].id } : undefined) + const currentLayout = state.layoutByWorktree[worktreeId] + const layoutChanged = nextLayout !== currentLayout + let patch: Partial = {} + + if ( + tabsChanged || + groupsChanged || + activeGroupChanged || + layoutChanged || + orphanTerminalIds.size > 0 + ) { + const droppedTerminalEntityIds = unifiedTabs.flatMap((tab) => + tab.contentType === 'terminal' && !validTabIds.has(tab.id) ? [tab.entityId] : [] + ) + let nextUnreadTerminalTabs = state.unreadTerminalTabs + if (droppedTerminalEntityIds.length > 0) { + const copy = { ...state.unreadTerminalTabs } + let changed = false + for (const entityId of droppedTerminalEntityIds) { + if (copy[entityId]) { + delete copy[entityId] + changed = true + } + } + if (changed) { + nextUnreadTerminalTabs = copy + } + } + patch = { + unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: validTabs }, + groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: nextActiveGroupId + }, + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...(nextLayout && layoutChanged + ? { + layoutByWorktree: { + ...state.layoutByWorktree, + // Why: restored runtime terminals need a concrete leaf before activation. + [worktreeId]: nextLayout + } + } + : {}), + ...(orphanTerminalIds.size > 0 + ? buildOrphanTerminalCleanupPatch(state, worktreeId, orphanTerminalIds) + : {}) + } + } + + return { + patch, + renderableTabCount: validTabs.length, + activeRenderableTabId: + nextGroups.find((group) => group.id === nextActiveGroupId)?.activeTabId ?? + nextGroups.find((group) => group.activeTabId !== null)?.activeTabId ?? + null + } +} + export const createTabsSlice: StateCreator = (set, get) => ({ unifiedTabsByWorktree: {}, renamingTabId: null, @@ -1807,220 +2016,13 @@ export const createTabsSlice: StateCreator = (set, }, reconcileWorktreeTabModel: (worktreeId) => { - const state = get() - const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const groups = state.groupsByWorktree[worktreeId] ?? [] - const runtimeTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] - const unifiedTerminalEntityIds = new Set( - unifiedTabs.filter((tab) => tab.contentType === 'terminal').map((tab) => tab.entityId) - ) - const legacyRuntimeTerminalTabs = runtimeTerminalTabs.filter((tab) => { - if (unifiedTerminalEntityIds.has(tab.id)) { - return false - } - // Why: migration filter — keep any tab still owning a live/reconnecting - // PTY (preserved sessionId or a reconnect-map session) so it re-enters the - // unified model for wake/reconnect reattach instead of vanishing (#9911). - return terminalTabHasReconnectablePty(state, tab.id, tab.ptyId) - }) - const orphanTerminalIds = getOrphanTerminalIds(state, worktreeId) - const ensuredGroupState = - legacyRuntimeTerminalTabs.length > 0 - ? ensureGroup( - state.groupsByWorktree, - state.activeGroupIdByWorktree, - worktreeId, - state.activeGroupIdByWorktree[worktreeId] - ) - : null - const reconciliationGroup = ensuredGroupState?.group ?? groups[0] ?? null - const restoredLegacyTabs = - reconciliationGroup == null - ? [] - : legacyRuntimeTerminalTabs - .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) - .map((tab) => ({ - id: tab.id, - entityId: tab.id, - groupId: reconciliationGroup.id, - worktreeId, - contentType: 'terminal' as const, - label: tab.title, - ...(tab.quickCommandLabel?.trim() - ? { quickCommandLabel: tab.quickCommandLabel.trim() } - : {}), - ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), - customLabel: tab.customTitle, - color: tab.color, - sortOrder: tab.sortOrder, - createdAt: tab.createdAt - })) - const reconciledUnifiedTabs = - restoredLegacyTabs.length > 0 ? [...unifiedTabs, ...restoredLegacyTabs] : unifiedTabs - // Why: seed a freshly-ensured group's active tab from the remembered selection, else the worktree always reopens on Terminal 1. - const rememberedLegacyActiveTabId = state.activeTabIdByWorktree[worktreeId] - const restoredLegacyTabIds = new Set(restoredLegacyTabs.map((tab) => tab.id)) - const legacyFallbackActiveTabId = - rememberedLegacyActiveTabId && restoredLegacyTabIds.has(rememberedLegacyActiveTabId) - ? rememberedLegacyActiveTabId - : (restoredLegacyTabs[0]?.id ?? null) - const reconciledGroups = - restoredLegacyTabs.length > 0 && reconciliationGroup - ? updateGroup(ensuredGroupState!.groupsByWorktree[worktreeId] ?? [], { - ...reconciliationGroup, - // Why: restore runtime-slice legacy tabs into the active/root group so live PTYs stay reachable instead of spawning a duplicate. - activeTabId: reconciliationGroup.activeTabId ?? legacyFallbackActiveTabId, - tabOrder: dedupeTabOrder([ - ...reconciliationGroup.tabOrder, - ...restoredLegacyTabs.map((tab) => tab.id) - ]) - }) - : groups - const liveTerminalIds = new Set( - runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) - ) - const liveEditorIds = new Set( - state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) - ) - const liveBrowserIds = new Set( - (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) - ) - - const isRenderableTab = (tab: Tab): boolean => { - if (tab.contentType === 'terminal') { - return liveTerminalIds.has(tab.entityId) - } - if (tab.contentType === 'browser') { - return liveBrowserIds.has(tab.entityId) - } - if (tab.contentType === 'simulator') { - return true - } - return liveEditorIds.has(tab.entityId) + const reconciliation = projectWorktreeTabModelReconciliation(get(), worktreeId) + if (Object.keys(reconciliation.patch).length > 0) { + set(reconciliation.patch) } - - const validTabs = reconciledUnifiedTabs.filter(isRenderableTab) - const validTabIds = new Set(validTabs.map((tab) => tab.id)) - - const nextGroupsWithEmpty = reconciledGroups.map((group) => { - const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) - const activeTabId = - group.activeTabId && validTabIds.has(group.activeTabId) - ? group.activeTabId - : (tabOrder[0] ?? null) - const tabOrderUnchanged = - tabOrder.length === group.tabOrder.length && - tabOrder.every((tabId, index) => tabId === group.tabOrder[index]) - // Why: keep the MRU stack in sync with dropped tabs so the next close doesn't activate one the renderer no longer owns. - const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder) - const recentUnchanged = - recentTabIds.length === (group.recentTabIds ?? []).length && - recentTabIds.every((id, index) => id === (group.recentTabIds ?? [])[index]) - return tabOrderUnchanged && activeTabId === group.activeTabId && recentUnchanged - ? group - : { ...group, tabOrder, activeTabId, recentTabIds } - }) - const nextGroups = - validTabs.length > 0 - ? nextGroupsWithEmpty.filter((group) => group.tabOrder.length > 0) - : nextGroupsWithEmpty - - const currentActiveGroupId = - state.activeGroupIdByWorktree[worktreeId] ?? - ensuredGroupState?.activeGroupIdByWorktree[worktreeId] - const activeGroupStillExists = nextGroups.some((group) => group.id === currentActiveGroupId) - const nextActiveGroupId = activeGroupStillExists - ? currentActiveGroupId - : (nextGroups.find((group) => group.activeTabId !== null)?.id ?? - nextGroups[0]?.id ?? - currentActiveGroupId) - - const groupsChanged = - nextGroups.length !== groups.length || - nextGroups.some((group, index) => group !== groups[index]) - const tabsChanged = validTabs.length !== unifiedTabs.length || restoredLegacyTabs.length > 0 - const activeGroupChanged = nextActiveGroupId !== currentActiveGroupId - - const baseNextLayout = - restoredLegacyTabs.length > 0 && reconciliationGroup - ? (state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: reconciliationGroup.id }) - : state.layoutByWorktree[worktreeId] - const validGroupIds = new Set(nextGroups.map((group) => group.id)) - const prunedNextLayout = - baseNextLayout && validGroupIds.size > 0 - ? pruneTabGroupLayoutForGroups(baseNextLayout, validGroupIds) - : baseNextLayout - const nextLayout = - prunedNextLayout ?? (nextGroups[0] ? { type: 'leaf', groupId: nextGroups[0].id } : undefined) - const currentLayout = state.layoutByWorktree[worktreeId] - const layoutChanged = nextLayout !== currentLayout - - if ( - tabsChanged || - groupsChanged || - activeGroupChanged || - layoutChanged || - orphanTerminalIds.size > 0 - ) { - // Why: drop unreadTerminalTabs entries (keyed by entityId) for terminal tabs reconcile removed, or the stale flag lingers forever. - const droppedTerminalEntityIds: string[] = [] - for (const tab of unifiedTabs) { - if (tab.contentType !== 'terminal') { - continue - } - if (!validTabIds.has(tab.id)) { - droppedTerminalEntityIds.push(tab.entityId) - } - } - set((current) => { - let nextUnreadTerminalTabs = current.unreadTerminalTabs - if (droppedTerminalEntityIds.length > 0) { - let changed = false - const copy = { ...current.unreadTerminalTabs } - for (const entityId of droppedTerminalEntityIds) { - if (copy[entityId]) { - delete copy[entityId] - changed = true - } - } - if (changed) { - nextUnreadTerminalTabs = copy - } - } - return { - unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: validTabs }, - groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: nextGroups }, - activeGroupIdByWorktree: { - ...current.activeGroupIdByWorktree, - [worktreeId]: nextActiveGroupId - }, - ...(nextUnreadTerminalTabs !== current.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...(nextLayout && layoutChanged - ? { - layoutByWorktree: { - ...current.layoutByWorktree, - // Why: restored runtime terminal needs a concrete leaf before activation, else the fallback spawns a duplicate "Terminal 2". - [worktreeId]: nextLayout! - } - } - : {}), - ...(orphanTerminalIds.size > 0 - ? buildOrphanTerminalCleanupPatch(current, worktreeId, orphanTerminalIds) - : {}) - } - }) - } - - const activeRenderableTabId = - nextGroups.find((group) => group.id === nextActiveGroupId)?.activeTabId ?? - nextGroups.find((group) => group.activeTabId !== null)?.activeTabId ?? - null - return { - renderableTabCount: validTabs.length, - activeRenderableTabId + renderableTabCount: reconciliation.renderableTabCount, + activeRenderableTabId: reconciliation.activeRenderableTabId } }, diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index fd548c91e04..75ad5985153 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -35,6 +35,7 @@ import type { WorktreeCreationPhase } from '@/lib/pending-worktree-creation' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import type { AppState } from '../types' export { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' export type WorktreeDeleteState = { @@ -70,6 +71,12 @@ export type WorktreeRenameRequest = { rowKey?: string } +export type ActiveWorktreeStateTransition = (state: AppState) => { + patch: Partial + activate: boolean + preferredActiveUnifiedTabId?: string +} + export type WorktreeSlice = { worktreesByRepo: Record detectedWorktreesByRepo: Record @@ -280,7 +287,11 @@ export type WorktreeSlice = { * fresh visit. */ seedActiveWorktreeLastVisitedIfMissing: () => void - setActiveWorktree: (worktreeId: string | null, executionHostId?: ExecutionHostId) => void + setActiveWorktree: ( + worktreeId: string | null, + executionHostId?: ExecutionHostId, + options?: { stateTransition?: ActiveWorktreeStateTransition } + ) => boolean /** * Health-driven remount of one terminal tab: bumps the tab's generation so * TerminalPane unmounts, detaches (preserving a live PTY), and remounts with diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 66bf295a715..310e3e82d8d 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -26,6 +26,7 @@ import { type WorktreeFetchOptions, type WorktreeSlice } from './worktree-helpers' +import { projectWorktreeTabModelReconciliation } from './tabs' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { areWorkspaceLinkedItemsEqual } from '../../../../shared/workspace-linked-item' import { areTaskSourceContextsEqual } from '../../../../shared/task-source-context' @@ -5238,7 +5239,14 @@ export const createWorktreeSlice: StateCreator return remounted }, - setActiveWorktree: (worktreeId, executionHostId) => { + setActiveWorktree: (worktreeId, executionHostId, options) => { + const stateTransition = options?.stateTransition?.(get()) + if (stateTransition && !stateTransition.activate) { + if (Object.keys(stateTransition.patch).length > 0) { + set(stateTransition.patch) + } + return false + } const workspaceScope = worktreeId ? parseWorkspaceKey(worktreeId) : null if (worktreeId && shouldDeferActivationTerminalPrep()) { markInputQuietSchedulerInput() @@ -5247,15 +5255,27 @@ export const createWorktreeSlice: StateCreator if (get().activeWorktreeId !== worktreeId) { moveFocusToRendererBeforeFocusedWebviewHidden() } - const reconciledActiveTabId = worktreeId - ? get().reconcileWorktreeTabModel(worktreeId).activeRenderableTabId - : null let shouldClearUnread = false let shouldPrepareTerminalTabs = false let shouldTagTerminalTabs = false - set((s) => { + set((current) => { + const transitioned = stateTransition + ? ({ ...current, ...stateTransition.patch } as AppState) + : current + const reconciliation = worktreeId + ? projectWorktreeTabModelReconciliation(transitioned, worktreeId) + : null + const reconciliationChanged = Boolean( + reconciliation && Object.keys(reconciliation.patch).length > 0 + ) + const s = + reconciliation && reconciliationChanged + ? ({ ...transitioned, ...reconciliation.patch } as AppState) + : transitioned + const reconciledActiveTabId = reconciliation?.activeRenderableTabId ?? null if (!worktreeId) { return { + ...stateTransition?.patch, activeWorktreeId: null, activeWorkspaceKey: null, activeWorkspaceExecutionHostId: null, @@ -5279,7 +5299,11 @@ export const createWorktreeSlice: StateCreator ? ((s.groupsByWorktree[worktreeId] ?? []).find((group) => group.id === activeGroupId) ?? null) : null - const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null + const activeUnifiedTabId = + stateTransition?.preferredActiveUnifiedTabId ?? + reconciledActiveTabId ?? + activeGroup?.activeTabId ?? + null const activeUnifiedTab = activeUnifiedTabId != null ? ((s.unifiedTabsByWorktree[worktreeId] ?? []).find( @@ -5408,7 +5432,12 @@ export const createWorktreeSlice: StateCreator : workspace ) : s.folderWorkspaces - const nextActiveRepoId = workspaceScope?.type === 'folder' ? null : s.activeRepoId + const nextActiveRepoId = + workspaceScope?.type === 'folder' + ? null + : stateTransition + ? (worktree?.repoId ?? s.activeRepoId) + : s.activeRepoId const tabsByWorktreeUpdate = allDead && worktreeId != null ? { @@ -5444,13 +5473,17 @@ export const createWorktreeSlice: StateCreator nextWorktrees !== s.worktreesByRepo || nextDetectedWorktrees !== s.detectedWorktreesByRepo || nextFolderWorkspaces !== s.folderWorkspaces || - nextActiveRepoId !== s.activeRepoId + nextActiveRepoId !== s.activeRepoId || + reconciliationChanged || + stateTransition !== undefined if (!hasStateChange) { // Why: preserve the root Zustand reference on a no-op re-activation so session persistence/runtime sync don't fan out. return s } return { + ...stateTransition?.patch, + ...reconciliation?.patch, activeRepoId: nextActiveRepoId, activeWorktreeId: worktreeId, activeWorkspaceKey: isWorkspaceKey(worktreeId) @@ -5531,13 +5564,13 @@ export const createWorktreeSlice: StateCreator } if (!worktreeId || !get().getKnownWorktreeById(worktreeId, executionHostId)) { - return + return true } if (shouldClearUnread) { if (workspaceScope?.type === 'folder') { void get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, { isUnread: false }) - return + return true } persistPassiveWorktreeMetaForOwner( get, @@ -5546,6 +5579,7 @@ export const createWorktreeSlice: StateCreator 'persist worktree activation state' ) } + return true }, setActiveFolderWorkspace: (folderWorkspaceId, executionHostId) => { diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index d4d0efc1ad8..6e012e60b42 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -409,8 +409,8 @@ export type RuntimeTerminalPathOpenTarget = } /** Result of resolving a file path tapped in the mobile terminal against the - * worktree root (+ optional cwd). relativePath is null when the path resolves - * outside the worktree (not openable via the worktree-scoped file RPCs). */ + * selected or sibling workspace root (+ optional cwd). relativePath is null + * when no workspace on the same execution host owns the path. */ export type RuntimeTerminalPathResolution = { worktree: string relativePath: string | null diff --git a/src/shared/runtime-workspace-file-owner.test.ts b/src/shared/runtime-workspace-file-owner.test.ts new file mode 100644 index 00000000000..c6d003b8ea6 --- /dev/null +++ b/src/shared/runtime-workspace-file-owner.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { findRuntimeWorkspaceFileOwner } from './runtime-workspace-file-owner' + +describe('findRuntimeWorkspaceFileOwner', () => { + const roots = [ + { workspaceId: 'repo-a', rootPath: '/srv/repo-a', executionHostId: 'runtime:host-a' as const }, + { workspaceId: 'repo-b', rootPath: '/srv/repo-b', executionHostId: 'runtime:host-a' as const }, + { + workspaceId: 'repo-b-docs', + rootPath: '/srv/repo-b/docs', + executionHostId: 'runtime:host-a' as const + }, + { + workspaceId: 'other-host', + rootPath: '/srv/repo-b', + executionHostId: 'runtime:host-b' as const + } + ] + + it('finds a sibling workspace on the same execution host', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/src/index.ts', 'runtime:host-a') + ).toMatchObject({ + workspaceId: 'repo-b', + relativePath: 'src/index.ts' + }) + }) + + it('uses the most specific nested workspace root', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/docs/guide.md', 'runtime:host-a') + ).toMatchObject({ + workspaceId: 'repo-b-docs', + relativePath: 'guide.md' + }) + }) + + it('uses an exact nested workspace root instead of its parent', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/docs', 'runtime:host-a') + ).toMatchObject({ + workspaceId: 'repo-b-docs', + relativePath: '' + }) + }) + + it('does not cross execution-host boundaries', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/src/index.ts', 'runtime:host-b') + ).toMatchObject({ + workspaceId: 'other-host' + }) + expect(findRuntimeWorkspaceFileOwner(roots, '/srv/repo-a/file.ts', 'runtime:host-b')).toBeNull() + }) + + it('matches Windows roots without case-sensitive drive assumptions', () => { + expect( + findRuntimeWorkspaceFileOwner( + [ + { + workspaceId: 'windows-repo', + rootPath: 'C:\\Work\\Repo', + executionHostId: 'runtime:windows' + } + ], + 'c:\\work\\repo\\src\\index.ts', + 'runtime:windows' + ) + ).toMatchObject({ + workspaceId: 'windows-repo', + relativePath: 'src/index.ts' + }) + }) +}) diff --git a/src/shared/runtime-workspace-file-owner.ts b/src/shared/runtime-workspace-file-owner.ts new file mode 100644 index 00000000000..1edacb07bbc --- /dev/null +++ b/src/shared/runtime-workspace-file-owner.ts @@ -0,0 +1,43 @@ +import type { ExecutionHostId } from './execution-host' +import { normalizeRuntimePathForComparison, relativePathInsideRoot } from './cross-platform-path' + +export type RuntimeWorkspaceFileRoot = { + workspaceId: string + rootPath: string + executionHostId: ExecutionHostId +} + +export type RuntimeWorkspaceFileOwner = RuntimeWorkspaceFileRoot & { + relativePath: string +} + +export function findRuntimeWorkspaceFileOwner( + roots: readonly RuntimeWorkspaceFileRoot[], + absolutePath: string, + executionHostId: ExecutionHostId +): RuntimeWorkspaceFileOwner | null { + let best: RuntimeWorkspaceFileOwner | null = null + let bestRootLength = -1 + + for (const root of roots) { + if (root.executionHostId !== executionHostId) { + continue + } + const relativePath = relativePathInsideRoot(root.rootPath, absolutePath) + if (relativePath === null) { + continue + } + const rootLength = normalizeRuntimePathForComparison(root.rootPath).length + if ( + rootLength > bestRootLength || + (rootLength === bestRootLength && + best !== null && + root.workspaceId.localeCompare(best.workspaceId) < 0) + ) { + best = { ...root, relativePath } + bestRootLength = rootLength + } + } + + return best +} From e43bd6c4adb1f1073c928e8788d4efbc834f3646 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:29:19 -0700 Subject: [PATCH 030/101] fix(worktrees): preserve folder PTY owner scope (#12429) --- src/main/ipc/worktrees.test.ts | 33 +++++++++++++++++++++++++++ src/main/ipc/worktrees.ts | 6 ++--- src/main/runtime/orca-runtime.test.ts | 4 +++- src/main/runtime/orca-runtime.ts | 24 ++++++++----------- 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 8d576b965ee..c83eece63b6 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -8380,6 +8380,8 @@ describe('registerWorktreeHandlers', () => { connectionId: 'conn-1' }) getSshPtyProviderMock.mockReturnValue(sshPtyProvider) + // One global meta key can describe the same-id local copy; the resolved repo still owns this delete. + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ hostId: 'local' })) await handlers['worktrees:remove'](null, { worktreeId }) @@ -8395,6 +8397,37 @@ describe('registerWorktreeHandlers', () => { }) }) + it('fences a mirrored runtime folder workspace sweep to its environment', async () => { + const runtimePtyProvider = {} as never + const worktreeId = 'repo-folder::/runtime/folder::workspace:child-1' + store.getRepo.mockReturnValue({ + id: 'repo-folder', + path: '/runtime/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder', + executionHostId: 'runtime:env-1' + }) + getLocalPtyProviderMock.mockReturnValue(runtimePtyProvider) + + await handlers['worktrees:remove'](null, { + worktreeId, + hostId: 'runtime:env-1' + }) + + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, { + runtime: runtimeStub, + resolvedWorktreeId: worktreeId, + resolvedRuntimeEnvironmentId: 'env-1', + localProvider: runtimePtyProvider, + onPtyStopped: clearProviderPtyStateMock, + includeProviderInventory: false, + includeLocalRegistry: false + }) + expect(getSshPtyProviderMock).not.toHaveBeenCalled() + }) + it('runs the archive hook on remove when skipArchive is not set', async () => { mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue(undefined) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index dde33ca7628..c8dceec6bed 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -2297,9 +2297,9 @@ export function registerWorktreeHandlers( await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => { // Folder projects can be SSH-backed, so fence the sweep to the owning host exactly // like the git paths — the local inventory must never reach a remote workspace's id. - const ownerHost = parseExecutionHostId( - resolveWorktreeRemovalOwnerHostId(store, args.worktreeId, repo, args.hostId) - ) + // The resolved repo is authoritative here: path-derived metadata is shared by + // same-id host copies and can describe a different owner's workspace. + const ownerHost = parseExecutionHostId(removalHostId) const sshPtyProvider = ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3aa249a0d99..722acf67937 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3943,7 +3943,9 @@ describe('OrcaRuntimeService', () => { displayName: 'Folder', badgeColor: 'blue', addedAt: 1, - kind: 'folder' as const + kind: 'folder' as const, + // removeManagedWorktree executes inside this selected runtime, where PTYs are local ids. + executionHostId: 'runtime:env-1' as const } const rootWorktreeId = 'folder-repo::/workspace/folder' const rootPriorWorktreeIds = ['folder-repo::/workspace/old-folder'] diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 088f52d5b26..849cfdc9c72 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -23511,31 +23511,25 @@ export class OrcaRuntimeService { 'Cannot delete the project root workspace. Remove the folder project instead.' ) } - // Folder projects can be SSH-backed, so resolve the owner before sweeping. - const folderHost = parseExecutionHostId( - store.getWorktreeMeta(removalTarget.id)?.hostId ?? getRepoExecutionHostId(repo) - ) - const folderSshPtyProvider = - folderHost?.kind === 'ssh' ? this.getSshProviderFn?.(folderHost.targetId) : undefined - const externalFolderHost = folderHost?.kind === 'ssh' || folderHost?.kind === 'runtime' + // This service runs inside the selected runtime, so runtime-stamped repos use its + // local PTY namespace; only a direct SSH connection is external from here. + const folderConnectionId = repo.connectionId?.trim() || null + const folderSshPtyProvider = folderConnectionId + ? this.getSshProviderFn?.(folderConnectionId) + : undefined const folderPtyProvider = folderSshPtyProvider ?? this.getLocalProvider() if (folderPtyProvider) { // Why: folder workspace deletion has no Git removal phase where PTYs // would otherwise be swept; tear them down before hiding the workspace. await killAllProcessesForWorktree(removalTarget.id, { runtime: this, - // External host inventories must never sweep a same-id local workspace. resolvedWorktreeId: removalTarget.id, - ...(folderHost?.kind === 'ssh' ? { resolvedConnectionId: folderHost.targetId } : {}), - ...(folderHost?.kind === 'runtime' - ? { resolvedRuntimeEnvironmentId: folderHost.environmentId } - : {}), + ...(folderConnectionId ? { resolvedConnectionId: folderConnectionId } : {}), localProvider: folderPtyProvider, onPtyStopped: this.onPtyStopped ?? undefined, - ...(externalFolderHost + ...(folderConnectionId ? { - includeProviderInventory: - folderHost?.kind === 'ssh' && Boolean(folderSshPtyProvider), + includeProviderInventory: Boolean(folderSshPtyProvider), includeLocalRegistry: false } : {}) From 086a5d6495fc47b5a234fa399e71565851979124 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:41:14 -0700 Subject: [PATCH 031/101] fix(browser): rerun guest retention after veto release (#12430) --- src/renderer/src/components/Terminal.tsx | 24 +++++++++++- .../browser-automation-visibility.test.ts | 8 ++++ .../browser-automation-visibility.ts | 4 ++ .../browser-page-download-activity.test.ts | 9 ++++- .../browser-page-download-activity.ts | 37 +++++++++++++------ 5 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index deae7c9d1a8..6eaf0afc78b 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -49,10 +49,12 @@ import { RetainedBrowserPaneOverlayLayer } from './browser-pane/BrowserPaneOverl import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' import { isBrowserAutomationVisible, + onBrowserAutomationVisibilityChange, useBrowserAutomationVisibilityForAny } from './browser-pane/browser-automation-visibility' import { isBrowserPageMobileDriven, + onBrowserDriverChange, useBrowserMobileDriverForAny } from '@/lib/pane-manager/browser-mobile-driver-state' import TerminalPaneOverlayLayer from './terminal-pane/TerminalPaneOverlayLayer' @@ -788,6 +790,7 @@ function Terminal(): React.JSX.Element | null { const measurableBackgroundWorktreeTimersRef = useRef(new Map()) const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) const [terminalParkingRevision, setTerminalParkingRevision] = useState(0) + const [browserGuestRetentionRevision, setBrowserGuestRetentionRevision] = useState(0) const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState>( () => new Set() ) @@ -1166,7 +1169,19 @@ function Terminal(): React.JSX.Element | null { ]) // Why here: downloads outlive the pane-local state of hidden (unmounted) // BrowserPanes, and the eviction veto below must see them. - useEffect(() => installBrowserPageDownloadActivityTracking(), []) + useEffect(() => { + const invalidateRetention = (): void => { + setBrowserGuestRetentionRevision((revision) => revision + 1) + } + const removeDownloadTracking = installBrowserPageDownloadActivityTracking(invalidateRetention) + const removeAutomationTracking = onBrowserAutomationVisibilityChange(invalidateRetention) + const removeMobileTracking = onBrowserDriverChange(invalidateRetention) + return () => { + removeDownloadTracking() + removeAutomationTracking() + removeMobileTracking() + } + }, []) // Browser-guest retention budget (#12137 follow-up): hidden worktrees keep // webview guests alive for instant revisits, but only the most recently // activated few. Older ones have every guest FULLY destroyed through the @@ -1231,7 +1246,12 @@ function Terminal(): React.JSX.Element | null { worktreeId ) } - }, [renderedActiveWorktreeId, workspaceSurfaces, browserGuestRetentionBudgetEnabled]) + }, [ + renderedActiveWorktreeId, + workspaceSurfaces, + browserGuestRetentionBudgetEnabled, + browserGuestRetentionRevision + ]) // Why: a slow post-reconnect step exposes workspaceSessionReady before hydration can populate snapshot capabilities. if ( renderedActiveWorktreeId && diff --git a/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts b/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts index dd15257ac87..14f4d8c9976 100644 --- a/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts +++ b/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts @@ -19,8 +19,11 @@ describe('browser automation visibility leases', () => { const { acquireBrowserAutomationVisibility, isBrowserAutomationVisible, + onBrowserAutomationVisibilityChange, releaseBrowserAutomationVisibility } = await import('./browser-automation-visibility') + const listener = vi.fn() + const unsubscribe = onBrowserAutomationVisibilityChange(listener) const first = acquireBrowserAutomationVisibility('page-1') const second = acquireBrowserAutomationVisibility('page-1') @@ -32,6 +35,11 @@ describe('browser automation visibility leases', () => { expect(releaseBrowserAutomationVisibility(second)).toBe(true) expect(isBrowserAutomationVisible('page-1')).toBe(false) + expect(listener).toHaveBeenCalledTimes(4) + + unsubscribe() + acquireBrowserAutomationVisibility('page-2') + expect(listener).toHaveBeenCalledTimes(4) }) it('installs a main-process bridge that keeps the page visible while waiting for paint', async () => { diff --git a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts index 6b092f2c950..f810d22f024 100644 --- a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts +++ b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts @@ -34,6 +34,10 @@ function subscribe(listener: () => void): () => void { } } +export function onBrowserAutomationVisibilityChange(listener: () => void): () => void { + return subscribe(listener) +} + function getSnapshot(): number { return version } diff --git a/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts b/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts index d428f20b409..cfd9c3f2e0c 100644 --- a/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts +++ b/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts @@ -74,21 +74,26 @@ describe('browser page download activity', () => { it('deactivates an interrupted download (no finished event ever fires) and reactivates on resume', async () => { const { hasActiveBrowserPageDownload, installBrowserPageDownloadActivityTracking } = await import('./browser-page-download-activity') - installBrowserPageDownloadActivityTracking() + const onEvictionVetoChange = vi.fn() + installBrowserPageDownloadActivityTracking(onEvictionVetoChange) requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) progressCallbacks[0]({ downloadId: 'dl-1', state: 'interrupted' }) expect(hasActiveBrowserPageDownload('page-1')).toBe(false) + expect(onEvictionVetoChange).toHaveBeenCalledTimes(2) progressCallbacks[0]({ downloadId: 'dl-1', state: 'progressing' }) expect(hasActiveBrowserPageDownload('page-1')).toBe(true) - // A null state is transport noise, not a transition. + // Duplicate progress and null transport noise are not transitions. + progressCallbacks[0]({ downloadId: 'dl-1', state: 'progressing' }) progressCallbacks[0]({ downloadId: 'dl-1', state: null }) expect(hasActiveBrowserPageDownload('page-1')).toBe(true) + expect(onEvictionVetoChange).toHaveBeenCalledTimes(3) finishedCallbacks[0]({ downloadId: 'dl-1' }) expect(hasActiveBrowserPageDownload('page-1')).toBe(false) + expect(onEvictionVetoChange).toHaveBeenCalledTimes(4) }) it('ignores duplicate start events, unknown progress and unknown finish events', async () => { diff --git a/src/renderer/src/components/browser-pane/browser-page-download-activity.ts b/src/renderer/src/components/browser-pane/browser-page-download-activity.ts index 0b3e1af2895..4c2b270ed8c 100644 --- a/src/renderer/src/components/browser-pane/browser-page-download-activity.ts +++ b/src/renderer/src/components/browser-pane/browser-page-download-activity.ts @@ -12,7 +12,11 @@ export function hasActiveBrowserPageDownload(browserPageId: string): boolean { return (activeDownloadCountByPageId.get(browserPageId) ?? 0) > 0 } -function setDownloadActive(download: TrackedDownload, active: boolean): void { +function setDownloadActive( + download: TrackedDownload, + active: boolean, + onEvictionVetoChange: () => void +): void { if (download.active === active) { return } @@ -23,50 +27,61 @@ function setDownloadActive(download: TrackedDownload, active: boolean): void { } else { activeDownloadCountByPageId.set(download.browserPageId, count) } + onEvictionVetoChange() } -function trackDownloadStarted(downloadId: string, browserPageId: string): void { +function trackDownloadStarted( + downloadId: string, + browserPageId: string, + onEvictionVetoChange: () => void +): void { if (trackedDownloadsById.has(downloadId)) { return } const download: TrackedDownload = { browserPageId, active: false } trackedDownloadsById.set(downloadId, download) - setDownloadActive(download, true) + setDownloadActive(download, true, onEvictionVetoChange) } // Why: an interrupted-resumable download never fires Chromium's 'done' (no // finished event), and Orca has no resume path — without this transition the // veto would outlive the download for the whole session. A resumed download // re-actives on its next 'progressing' tick, restoring the veto. -function trackDownloadProgress(downloadId: string, state: 'progressing' | 'interrupted' | null): void { +function trackDownloadProgress( + downloadId: string, + state: 'progressing' | 'interrupted' | null, + onEvictionVetoChange: () => void +): void { const download = trackedDownloadsById.get(downloadId) if (!download || state === null) { return } - setDownloadActive(download, state === 'progressing') + setDownloadActive(download, state === 'progressing', onEvictionVetoChange) } -function trackDownloadFinished(downloadId: string): void { +function trackDownloadFinished(downloadId: string, onEvictionVetoChange: () => void): void { const download = trackedDownloadsById.get(downloadId) if (!download) { return } - setDownloadActive(download, false) + setDownloadActive(download, false, onEvictionVetoChange) trackedDownloadsById.delete(downloadId) } /** App-lifetime tracking; install once from the surface host (Terminal). The * cleanup clears tracked state — a host unmount tears down every guest, so * stale entries must not veto eviction after a remount. */ -export function installBrowserPageDownloadActivityTracking(): () => void { +export function installBrowserPageDownloadActivityTracking( + onEvictionVetoChange: () => void = () => {} +): () => void { const removeRequested = window.api.browser.onDownloadRequested((event) => { - trackDownloadStarted(event.downloadId, event.browserPageId) + trackDownloadStarted(event.downloadId, event.browserPageId, onEvictionVetoChange) }) const removeProgress = window.api.browser.onDownloadProgress((event) => { - trackDownloadProgress(event.downloadId, event.state) + trackDownloadProgress(event.downloadId, event.state, onEvictionVetoChange) }) const removeFinished = window.api.browser.onDownloadFinished((event) => { - trackDownloadFinished(event.downloadId) + trackDownloadFinished(event.downloadId, onEvictionVetoChange) }) return () => { removeRequested() From ba83a71e302f98d1831af9673b2cd5300f1050a1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:48:22 -0700 Subject: [PATCH 032/101] fix(terminal): apply the running-process close confirmation to every tab close path (#10142) (#12272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(repro): demonstrate #10142 tab X close bypasses running-process confirmation Unit repro: closeTerminalTab (the X-button/middle-click entry) never consults inspectRuntimeTerminalProcess and drops a tab with a live child. E2E repro: Cmd+W shows 'Stop running command?' for a tab running sleep 300; cancelling then clicking the tab X closes it silently. Co-authored-by: Orca * fix(terminal): confirm running-process close on every tab close path (#10142) The tab-strip X button, middle-click and the tab context menu closed a terminal with a live child process without asking, while Cmd+W raised "Stop running command?" for the same tab. The probe lived only in TerminalPane's pane-level close handler; every mouse entry point reaches closeTerminalTab(), which guarded pinned tabs and nothing else. Move the decision into closeTerminalTab, above the web-runtime branch so paired/remote host-backed tabs are covered too, and give the last-pane keyboard close back to it instead of probing twice: - running-terminal-close-guard.ts probes every live PTY of the tab and fails open on a rejected probe or a stale remote handle, matching what Cmd+W already did. No live PTY ids => fully synchronous close, so idle, parked and hibernated tabs keep today's behavior. - shouldConfirmRunningTerminalClose keeps lifecycle echoes, bulk closes, CLI/RPC closes and the post-confirmation re-entry off the modal path. - A standalone confirm store drives RunningTerminalCloseDialog, which reuses the existing CloseTerminalDialog (no new user-visible strings). The request carries the tab label because a tab-strip close can target a tab the user is not looking at, and dedupes by tab id. - TerminalPane.handleRequestClosePane now delegates the last pane to closeTerminalTab. Its transport ptyId is nullable by design, so the old path silently skipped the prompt mid-reattach; the pane keeps its own probe only for closing one pane of a split. - Agent panes win the dialog copy when a split has both an agent and a plain command busy, instead of depending on PTY spawn order. - Tab-group closeItem ran leaveWorktreeIfEmpty synchronously after a close that can now defer; it moves to onClosed and still honors skipEmptyCheck. Co-authored-by: Orca * fix(terminal): close the running-process confirmation gaps on every path (#10142) Follow-up hardening on the tab-close confirmation, from review of the first pass: - A pinned tab with `confirmClosePinnedTab` off never got the running-process prompt on any path, including Cmd+W, which is a regression against the old pane-level behavior: the pinned branch short-circuited on pinned-ness alone and re-entered with `force`, which the running guard excludes. The pin prompt now supersedes only when it will actually appear; with the setting off the close falls through to the running guard. - The probe chain had no `.catch`, so a throw in the decision (a copy-kind lookup on a tab id makePaneKey rejects, a store subscriber) left the tab silently unclosed with no user feedback. It now fails open, as the pane path it replaced did. - A wedged remote inspect RPC could leave the X button looking dead for its full 15s timeout. The probe is now bounded; every close path shares the bound, so keyboard and mouse still behave identically. - The agent-vs-command copy had two resolvers on exactly the keyboard/mouse seam this issue is about. terminal-close-copy-kind.ts is now the single policy; TerminalPane and the tab-strip guard both call it. - The running queue is async while the pinned queue is synchronous, so both could be pending at once and stack two modal overlays. The running dialog now waits for a visible pinned confirmation. - Deduping a repeat close request dropped the second caller's callbacks; it now folds them in, so both closes resolve from one prompt. Ticking "don't ask again" also drains queued prompts instead of showing one the user just opted out of, and a queued prompt no longer inherits the previous tab's tick. closeTerminalTab drops its private pinned predicate for the shared isUnifiedTabPinned, whose only consumer the previous commit had removed. * test(e2e): wait for `sleep` to own the terminal before closing it (#10142) The running-process close specs polled `hasChildProcesses` to decide the tab was busy, but macOS starts the shell under `login`, so an initialising terminal already reports a child before `sleep 300` runs. Both specs could therefore press close against a shell that never started the command: the probe correctly saw an idle terminal and closed without asking, and the adjudicated repro failed against a correct fix. Wait for `foregroundProcess === 'sleep'` instead. Assertions are unchanged, and the repro still fails at the pre-fix baseline (a92d8e0b0d). * fix(terminal): ask instead of closing when the close probe times out (#10142) Round-1 review follow-ups. - The 4s probe bound closed the tab outright, but `inspectRuntimeTerminalProcess` gives remote runtimes a 15s RPC timeout: any probe taking 4-15s silently killed a running remote command that Cmd+W used to prompt about, and the pane path now delegates its last-pane close to this guard. An unanswered probe is unknown, not idle, so the timeout raises the confirmation with every pty treated as a candidate. Failing open still applies to an *answered* probe (rejection, stale remote handle), which is the pre-existing pane behavior. - The split-pane Cmd+W probe had no bound at all; it now shares the same one, so the two paths give the same answer to the same question. - The renamed regression spec dropped its repro scaffolding: the hardcoded /tmp screenshot directory (also a cross-platform path violation) and the title/docblock that still described the bug as open. * fix(terminal): adopt the double-activation guard and layout pty lookup from #10167 (#10142) Cross-referenced against @innocarpe's #10167, which solved the same issue. Two things it got right that this branch did not: - Queue actions now hold off for 350ms after a queued request replaces the visible one, matching the sibling pinned-tab confirmation. Without it the second click of a double-click aimed at one tab lands on the next tab's prompt and kills a running process the user never saw asked about — the exact bug class this PR exists to close. - The pty lookup unions the layout bindings with ptyIdsByTabId. A mounting pane is bound into the layout before the liveness map catches up, and the store's own teardown collector unions both for that reason, so reading only the map let a close slip through that window with no prompt. Also replaces the render-time ref write that failed React Doctor's "Ref mutated during render" rule: the queued-request checkbox reset now goes through CloseTerminalDialog's existing subject-change reset instead of remounting via key, which keeps the exit animation on one element. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/renderer/src/App.tsx | 2 + src/renderer/src/components/Terminal.tsx | 3 +- .../tab-group/useTabGroupWorkspaceModel.ts | 13 +- .../terminal-pane/CloseTerminalDialog.tsx | 25 ++ .../RunningTerminalCloseDialog.test.tsx | 247 +++++++++++ .../RunningTerminalCloseDialog.tsx | 45 ++ .../components/terminal-pane/TerminalPane.tsx | 66 +-- .../use-terminal-pane-lifecycle.ts | 3 +- .../running-terminal-close-guard.test.ts | 409 ++++++++++++++++++ .../terminal/running-terminal-close-guard.ts | 154 +++++++ ...al-close-confirm-keyboard-vs-mouse.test.ts | 131 ++++++ .../terminal/terminal-close-copy-kind.test.ts | 67 +++ .../terminal/terminal-close-copy-kind.ts | 38 ++ .../terminal/terminal-tab-actions.ts | 56 ++- ...terminal-tab-close-running-confirm.test.ts | 263 +++++++++++ src/renderer/src/hooks/useIpcEvents.test.ts | 5 +- src/renderer/src/hooks/useIpcEvents.ts | 3 +- .../src/store/pinned-tab-close-guard.ts | 10 +- .../running-terminal-close-confirm.test.ts | 226 ++++++++++ .../store/running-terminal-close-confirm.ts | 132 ++++++ ...al-tab-close-running-confirm-mouse.spec.ts | 101 +++++ ...terminal-tab-close-running-confirm.spec.ts | 90 ++++ 22 files changed, 2033 insertions(+), 56 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.tsx create mode 100644 src/renderer/src/components/terminal/running-terminal-close-guard.test.ts create mode 100644 src/renderer/src/components/terminal/running-terminal-close-guard.ts create mode 100644 src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts create mode 100644 src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts create mode 100644 src/renderer/src/components/terminal/terminal-close-copy-kind.ts create mode 100644 src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts create mode 100644 src/renderer/src/store/running-terminal-close-confirm.test.ts create mode 100644 src/renderer/src/store/running-terminal-close-confirm.ts create mode 100644 tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts create mode 100644 tests/e2e/terminal-tab-close-running-confirm.spec.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b9765d9149c..81f72be49d8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -207,6 +207,7 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' import { translate } from '@/i18n/i18n' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' +import RunningTerminalCloseDialog from './components/terminal-pane/RunningTerminalCloseDialog' import WorktreeBaseFallbackDialog from './components/WorktreeBaseFallbackDialog' import { useOsc52ClipboardDefaultOnNotice } from './components/terminal-pane/osc52-clipboard-default-on-notice' import { @@ -2789,6 +2790,7 @@ function App(): React.JSX.Element { + {/* Why: Electron's drag-region hit-test is DOM-order-based (ignores z-index); render last so WindowControls stay clickable. */} {hasCustomTitleBar && } diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 6eaf0afc78b..dc39c3a0644 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1797,7 +1797,8 @@ function Terminal(): React.JSX.Element | null { ) { if (unifiedTab.contentType === 'terminal') { // Why: paired-host bulk close must revoke renderer resume and hook authority, not just remove the host session tab. - closeTerminalTab(unifiedTab.entityId) + // No running-process prompt: "Close Others" over N busy tabs would be a modal storm. + closeTerminalTab(unifiedTab.entityId, { skipRunningProcessConfirm: true }) } else { void closeWebRuntimeSessionTab({ worktreeId: activeWorktreeId, diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 4bb234b9764..f493f2e5807 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -235,10 +235,12 @@ export function useTabGroupWorkspaceModel({ worktreeId ) if (item.contentType === 'terminal') { - closeTerminalTab(item.entityId) - if (!opts?.skipEmptyCheck) { - leaveWorktreeIfEmpty() - } + // Why: closeTerminalTab can defer behind a pin / running-process dialog, so the + // empty check has to run on the actual close — never on cancel. + closeTerminalTab( + item.entityId, + opts?.skipEmptyCheck ? undefined : { onClosed: leaveWorktreeIfEmpty } + ) return } if (item.contentType === 'browser') { @@ -296,7 +298,8 @@ export function useTabGroupWorkspaceModel({ ) if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) { // Why: revoke local resume + hook authority before the host removes its canonical tab. - closeTerminalTab(item.entityId) + // No running-process prompt: a bulk close of N busy tabs would be a modal storm. + closeTerminalTab(item.entityId, { skipRunningProcessConfirm: true }) continue } if (item.contentType === 'browser') { diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx index 771d5e15e77..c4da5244457 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx @@ -17,17 +17,26 @@ export type CloseTerminalDialogCopyKind = 'command' | 'agent' export default function CloseTerminalDialog({ open, copyKind = 'command', + tabLabel, + subjectKey, onCancel, onConfirm }: { open: boolean copyKind?: CloseTerminalDialogCopyKind + /** Names the tab when the prompt can target a tab the user is not looking at + * (tab-strip X, middle-click). Omitted for the focused-pane keyboard path. */ + tabLabel?: string + /** Identifies what is being closed, for hosts that reuse one open dialog across a queue + * of confirmations. Changing it clears the previous subject's "don't ask again" tick. */ + subjectKey?: string onCancel: () => void onConfirm: (dontAskAgain: boolean) => void }): React.JSX.Element { const checkboxId = useId() const [dontAskAgain, setDontAskAgain] = useState(false) const [previousOpen, setPreviousOpen] = useState(open) + const [previousSubjectKey, setPreviousSubjectKey] = useState(subjectKey) // Why: each reopen represents a fresh confirmation, so clear the old choice // during render rather than briefly painting it while the dialog opens. @@ -38,7 +47,18 @@ export default function CloseTerminalDialog({ } } + // Why: a queued confirmation swaps the subject without ever closing the dialog, so the + // reopen reset above never fires. Ignore the swap to undefined as the dialog closes — + // clearing the tick mid-exit-animation would be visible for no reason. + if (subjectKey !== previousSubjectKey) { + setPreviousSubjectKey(subjectKey) + if (subjectKey !== undefined) { + setDontAskAgain(false) + } + } + const isAgent = copyKind === 'agent' + const trimmedTabLabel = tabLabel?.trim() return ( + {trimmedTabLabel ? ( +

+ {trimmedTabLabel} +

+ ) : null}
& { onConfirm: () => void }, + updateSettings: AppState['updateSettings'] +): Promise { + useAppStore.setState({ updateSettings }) + useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({ + terminalTabId: 'tab-1', + tabLabel: 'dev server', + copyKind: 'command', + ...request + }) + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + + await act(async () => { + root.render() + }) +} + +function getButton(label: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!button) { + throw new Error(`Button not found: ${label}`) + } + return button +} + +function getCheckbox(): HTMLButtonElement { + const checkbox = document.body.querySelector('[role="checkbox"]') + if (!checkbox) { + throw new Error('Checkbox not found') + } + return checkbox +} + +describe('RunningTerminalCloseDialog', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + // Monotonic across tests: the store is a singleton, so winding the clock back would + // leave a previous test's guard deadline in the future and block every action. + clock += 10_000 + vi.spyOn(Date, 'now').mockImplementation(() => clock) + }) + + afterEach(async () => { + while (useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm !== null) { + advancePastGuard() + useRunningTerminalCloseConfirmStore.getState().dismissRunningTerminalClose() + } + vi.mocked(Date.now).mockRestore() + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + useAppStore.setState(initialState, true) + }) + + it('names the tab so a background close is not ambiguous', async () => { + const onConfirm = vi.fn() + const updateSettings = vi.fn().mockResolvedValue(undefined) + + await renderDialog({ onConfirm }, updateSettings) + + expect(document.body.textContent).toContain('Stop running command?') + expect(document.body.textContent).toContain('dev server') + + await act(async () => { + getButton('Stop and Close').click() + }) + + expect(updateSettings).not.toHaveBeenCalled() + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('uses the agent copy for an agent pane', async () => { + const updateSettings = vi.fn().mockResolvedValue(undefined) + + await renderDialog({ onConfirm: vi.fn(), copyKind: 'agent' }, updateSettings) + + expect(document.body.textContent).toContain('Stop this agent?') + expect(getButton('Stop Agent')).toBeTruthy() + }) + + it('persists the opt-out when "don\'t ask again" is checked', async () => { + const onConfirm = vi.fn() + const updateSettings = vi.fn().mockResolvedValue(undefined) + + await renderDialog({ onConfirm }, updateSettings) + + await act(async () => { + getCheckbox().click() + }) + await act(async () => { + getButton('Stop and Close').click() + }) + + expect(updateSettings).toHaveBeenCalledWith({ + skipCloseTerminalWithRunningProcessConfirm: true + }) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + // Why: this queue opens after an async probe while the pinned queue opens synchronously, + // so both can be pending at once. Two modal overlays + focus traps is the bug. + it('waits for a visible pinned confirmation instead of stacking a second modal', async () => { + const updateSettings = vi.fn().mockResolvedValue(undefined) + useAppStore.setState({ + pinnedTabCloseConfirm: { tabLabel: 'pinned tab', onConfirm: vi.fn() } + }) + + await renderDialog({ onConfirm: vi.fn() }, updateSettings) + + expect(document.body.textContent).not.toContain('Stop running command?') + + await act(async () => { + useAppStore.setState({ pinnedTabCloseConfirm: null }) + }) + + expect(document.body.textContent).toContain('Stop running command?') + }) + + it('does not carry the opt-out tick over to the next queued tab', async () => { + const updateSettings = vi.fn().mockResolvedValue(undefined) + const nextOnConfirm = vi.fn() + + await renderDialog({ onConfirm: vi.fn(), onCancel: vi.fn() }, updateSettings) + await act(async () => { + useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({ + terminalTabId: 'tab-2', + tabLabel: 'build watcher', + copyKind: 'command', + onConfirm: nextOnConfirm + }) + }) + + await act(async () => { + getCheckbox().click() + }) + await act(async () => { + getButton('Cancel').click() + }) + + expect(document.body.textContent).toContain('build watcher') + expect(getCheckbox().getAttribute('data-state')).toBe('unchecked') + + advancePastGuard() + await act(async () => { + getButton('Stop and Close').click() + }) + + expect(updateSettings).not.toHaveBeenCalled() + expect(nextOnConfirm).toHaveBeenCalledTimes(1) + }) + + it('drops a queued prompt once the user opts out of asking again', async () => { + const updateSettings = vi.fn().mockResolvedValue(undefined) + const onConfirm = vi.fn() + const nextOnConfirm = vi.fn() + + await renderDialog({ onConfirm }, updateSettings) + await act(async () => { + useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({ + terminalTabId: 'tab-2', + tabLabel: 'build watcher', + copyKind: 'command', + onConfirm: nextOnConfirm + }) + }) + + await act(async () => { + getCheckbox().click() + }) + await act(async () => { + getButton('Stop and Close').click() + }) + + expect(updateSettings).toHaveBeenCalledWith({ + skipCloseTerminalWithRunningProcessConfirm: true + }) + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(nextOnConfirm).toHaveBeenCalledTimes(1) + expect(document.body.textContent).not.toContain('build watcher') + }) + + it('cancels without closing and shows the next queued tab', async () => { + const onConfirm = vi.fn() + const onCancel = vi.fn() + const nextOnConfirm = vi.fn() + const updateSettings = vi.fn().mockResolvedValue(undefined) + + await renderDialog({ onConfirm, onCancel }, updateSettings) + await act(async () => { + useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({ + terminalTabId: 'tab-2', + tabLabel: 'build watcher', + copyKind: 'command', + onConfirm: nextOnConfirm + }) + }) + + await act(async () => { + getButton('Cancel').click() + }) + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onConfirm).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('build watcher') + + advancePastGuard() + await act(async () => { + getButton('Stop and Close').click() + }) + + expect(nextOnConfirm).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.tsx b/src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.tsx new file mode 100644 index 00000000000..cf93fa77207 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.tsx @@ -0,0 +1,45 @@ +import { useAppStore } from '@/store' +import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' +import CloseTerminalDialog from './CloseTerminalDialog' + +/** Hosts the running-process close confirmation for tab-level closes (tab-strip X, + * middle-click, tab menu, tab groups, floating panel) so they share the prompt Cmd+W + * already raised. Store-driven, like PinnedTabCloseDialog, because those closes run + * outside any pane's React tree. */ +export default function RunningTerminalCloseDialog(): React.JSX.Element { + const request = useRunningTerminalCloseConfirmStore((state) => state.runningTerminalCloseConfirm) + const confirmClose = useRunningTerminalCloseConfirmStore( + (state) => state.confirmRunningTerminalClose + ) + const confirmAllCloses = useRunningTerminalCloseConfirmStore( + (state) => state.confirmAllRunningTerminalCloses + ) + const dismissClose = useRunningTerminalCloseConfirmStore( + (state) => state.dismissRunningTerminalClose + ) + const updateSettings = useAppStore((state) => state.updateSettings) + // Why: this queue is async (it opens after a probe) while the pinned queue is synchronous, + // so both can be pending at once. Wait rather than stack two modal overlays and focus traps. + const pinnedRequest = useAppStore((state) => state.pinnedTabCloseConfirm) + + return ( + { + if (dontAskAgain) { + void updateSettings({ skipCloseTerminalWithRunningProcessConfirm: true }) + // Why: the user just opted out of this prompt; a queued one must not still appear. + confirmAllCloses() + return + } + confirmClose() + }} + /> + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 9666ae98e53..c2b2149ba03 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -14,7 +14,6 @@ import { createPortal } from 'react-dom' import type { CSSProperties } from 'react' import type { IDisposable } from '@xterm/xterm' import { useAppStore } from '../../store' -import { isUnifiedTabPinned } from '@/store/pinned-tab-close-guard' import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog' import { DaemonActionDialog, useDaemonActions } from '@/components/shared/useDaemonActions' import { @@ -64,6 +63,8 @@ import type { MacOptionAsAlt } from './terminal-shortcut-policy' import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt' import { useTerminalFontZoom } from './useTerminalFontZoom' import CloseTerminalDialog, { type CloseTerminalDialogCopyKind } from './CloseTerminalDialog' +import { resolveLeafCloseCopyKind } from '../terminal/terminal-close-copy-kind' +import { RUNNING_CLOSE_PROBE_TIMEOUT_MS } from '../terminal/running-terminal-close-guard' import CodexRestartChip from '../CodexRestartChip' import { MobileDriverOverlay } from './MobileDriverOverlay' import { stripSshReconnectOwnedErrorLines, TerminalErrorToast } from './TerminalErrorToast' @@ -1288,30 +1289,21 @@ function TerminalPane( ) // Cmd+W confirms before killing a shell with a running child (e.g. npm run dev); idle prompts close immediately, and Ctrl+D bypasses by design. + // Why: the agent-vs-command rule is shared with the tab-strip prompt so the two close paths cannot word the same close differently (#10142). const getCloseDialogCopyKind = useCallback( - (paneId: number): CloseTerminalDialogCopyKind => { - const leafId = managerRef.current?.getLeafId(paneId) - if (!leafId) { - return 'command' - } - const agentType = - useAppStore.getState().agentStatusByPaneKey[makePaneKey(tabId, leafId)]?.agentType - return agentType && agentType !== 'unknown' ? 'agent' : 'command' - }, + (paneId: number): CloseTerminalDialogCopyKind => + resolveLeafCloseCopyKind(tabId, managerRef.current?.getLeafId(paneId)), [tabId] ) const handleRequestClosePane = useCallback( (paneId: number) => { - // Why: closing the last pane of a pinned tab prefers the pin dialog over the running-process prompt; non-pinned tabs keep the process prompt. - const isLastPane = (managerRef.current?.getPanes().length ?? 0) <= 1 - if (isLastPane) { - const state = useAppStore.getState() - const confirmPinned = state.settings?.confirmClosePinnedTab ?? true - if (confirmPinned && isUnifiedTabPinned(state, worktreeId, tabId)) { - executeClosePane(paneId) - return - } + // Why: the last pane closes the whole tab, and closeTerminalTab owns both the pinned + // and running-process guards. Probing here too would double-prompt, and its nullable + // transport ptyId would silently skip the prompt the mouse paths now get (#10142). + if ((managerRef.current?.getPanes().length ?? 0) <= 1) { + executeClosePane(paneId) + return } const transport = paneTransportsRef.current.get(paneId) const ptyId = transport?.getPtyId() @@ -1320,18 +1312,40 @@ function TerminalPane( return } const settings = useAppStore.getState().settings + // Why: same bound as the whole-tab guard, so a wedged remote probe never leaves Cmd+W + // looking dead for the full 15s RPC timeout; unanswered means ask, not close (#10142). + let decided = false + const decide = (act: () => void): void => { + if (decided) { + return + } + decided = true + act() + } + const confirmClose = (): void => + setPendingCloseConfirmation({ paneId, copyKind: getCloseDialogCopyKind(paneId) }) + const probeTimeout = setTimeout(() => decide(confirmClose), RUNNING_CLOSE_PROBE_TIMEOUT_MS) void inspectRuntimeTerminalProcess(settings, ptyId) .then((process) => { - if (!process.hasChildProcesses || settings?.skipCloseTerminalWithRunningProcessConfirm) { - executeClosePane(paneId) - } else { - setPendingCloseConfirmation({ paneId, copyKind: getCloseDialogCopyKind(paneId) }) - } + clearTimeout(probeTimeout) + decide(() => { + if ( + !process.hasChildProcesses || + settings?.skipCloseTerminalWithRunningProcessConfirm + ) { + executeClosePane(paneId) + } else { + confirmClose() + } + }) }) // Why: if the child-process probe rejects (wedged IPC, legacy provider), close anyway — Cmd+W doing nothing is worse than closing a pane with a child. - .catch(() => executeClosePane(paneId)) + .catch(() => { + clearTimeout(probeTimeout) + decide(() => executeClosePane(paneId)) + }) }, - [executeClosePane, tabId, worktreeId, getCloseDialogCopyKind] + [executeClosePane, getCloseDialogCopyKind] ) useImperativeHandle( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 23a7475f5dc..bc230ecd419 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -1664,7 +1664,8 @@ export function useTerminalPaneLifecycle({ manager: mgr, getPtyIdForLeaf: (leafId) => useAppStore.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId], - closeTab: () => closeTerminalTab(tabId), + // Why: CLI-driven pane close; its caller is answered immediately and cannot wait on a modal. + closeTab: () => closeTerminalTab(tabId, { skipRunningProcessConfirm: true }), closeTabPreservingPty: () => { const store = useAppStore.getState() if (detail.retireSurface && detail.leafId) { diff --git a/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts new file mode 100644 index 00000000000..3dc808e6abc --- /dev/null +++ b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getStateMock, inspectRuntimeTerminalProcessMock } = vi.hoisted(() => ({ + getStateMock: vi.fn(), + inspectRuntimeTerminalProcessMock: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock +})) + +import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' +import { + guardRunningTerminalClose, + shouldConfirmRunningTerminalClose, + RUNNING_CLOSE_PROBE_TIMEOUT_MS +} from './running-terminal-close-guard' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +function setState(overrides: Record = {}): void { + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + ptyIdsByTabId: { 'tab-1': ['pty-a'] }, + terminalLayoutsByTabId: { 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a' } } }, + agentStatusByPaneKey: {}, + ...overrides + }) +} + +function guard(onClose = vi.fn(), onCancel?: () => void): void { + guardRunningTerminalClose({ + terminalTabId: 'tab-1', + tabLabel: 'npm run dev', + onClose, + ...(onCancel ? { onCancel } : {}) + }) +} + +function visibleRequest() { + return useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm +} + +async function settleProbe(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('shouldConfirmRunningTerminalClose', () => { + it('confirms a plain interactive close', () => { + expect(shouldConfirmRunningTerminalClose(undefined)).toBe(true) + expect(shouldConfirmRunningTerminalClose({})).toBe(true) + expect(shouldConfirmRunningTerminalClose({ reason: 'user' })).toBe(true) + expect(shouldConfirmRunningTerminalClose({ hostCloseReason: 'user' })).toBe(true) + }) + + it.each([ + ['post-confirmation re-entry', { force: true }], + ['non-interactive CLI reject', { rejectPinned: true }], + ['bulk/CLI opt-out', { skipRunningProcessConfirm: true }], + ['pty lifecycle echo', { reason: 'pty-exit' as const }], + ['cleanup teardown', { reason: 'cleanup' as const }], + ['host-only pty lifecycle echo', { hostCloseReason: 'pty-exit' as const }], + ['pty-scoped lifecycle close', { lifecyclePtyId: 'pty-a' }] + ])('never prompts for a %s', (_label, options) => { + expect(shouldConfirmRunningTerminalClose(options)).toBe(false) + }) +}) + +describe('guardRunningTerminalClose', () => { + beforeEach(() => { + vi.clearAllMocks() + setState() + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'sleep', + hasChildProcesses: true + }) + }) + + afterEach(() => { + const store = useRunningTerminalCloseConfirmStore.getState() + while (visibleRequest()) { + store.dismissRunningTerminalClose() + } + }) + + it('closes synchronously and never probes when the tab has no live pty', () => { + setState({ ptyIdsByTabId: {}, terminalLayoutsByTabId: {} }) + const onClose = vi.fn() + + guard(onClose) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + }) + + // Why: a mounting pane is bound into the layout before ptyIdsByTabId catches up. Reading + // only the liveness map would let a close slip through that window with no prompt. + it('prompts for a pane the layout has bound but the liveness map has not', async () => { + setState({ ptyIdsByTabId: { 'tab-1': [] } }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(expect.anything(), 'pty-a') + expect(onClose).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1' }) + }) + + it('probes each pty once when the map and the layout name the same one', async () => { + guard() + await settleProbe() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledTimes(1) + }) + + it('closes without probing when the user turned the prompt off', () => { + setState({ + settings: { + activeRuntimeEnvironmentId: null, + skipCloseTerminalWithRunningProcessConfirm: true + } + }) + const onClose = vi.fn() + + guard(onClose) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(visibleRequest()).toBeNull() + }) + + it('closes an idle terminal without a prompt', async () => { + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + it('defers a busy terminal behind a confirmation that carries the tab label', async () => { + const onClose = vi.fn() + + guard(onClose) + expect(onClose).not.toHaveBeenCalled() + await settleProbe() + + expect(onClose).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ + terminalTabId: 'tab-1', + tabLabel: 'npm run dev', + copyKind: 'command' + }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('runs onCancel and keeps the tab when the confirmation is dismissed', async () => { + const onClose = vi.fn() + const onCancel = vi.fn() + + guard(onClose, onCancel) + await settleProbe() + useRunningTerminalCloseConfirmStore.getState().dismissRunningTerminalClose() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onClose).not.toHaveBeenCalled() + }) + + it('fails open and closes when the probe rejects (wedged relay / legacy provider)', async () => { + inspectRuntimeTerminalProcessMock.mockRejectedValue(new Error('rpc_timeout')) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + it('fails open when a remote handle reports the inspection as unavailable', async () => { + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + it('prompts once for a split tab where only the second pane is busy', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } } + }) + inspectRuntimeTerminalProcessMock.mockImplementation(async (_settings, ptyId: string) => ({ + foregroundProcess: ptyId === 'pty-b' ? 'claude' : 'zsh', + hasChildProcesses: ptyId === 'pty-b' + })) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledTimes(2) + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1', copyKind: 'agent' }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('does not use an idle sibling pane to pick the agent copy', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } } + }) + inspectRuntimeTerminalProcessMock.mockImplementation(async (_settings, ptyId: string) => ({ + foregroundProcess: ptyId === 'pty-a' ? 'npm' : 'zsh', + hasChildProcesses: ptyId === 'pty-a' + })) + + guard() + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('command') + }) + + it('prefers the agent copy regardless of pty spawn order when both panes are busy', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'codex' } } + }) + + guard() + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('agent') + }) + + it('ignores a pane whose agent status is unknown', async () => { + setState({ agentStatusByPaneKey: { [`tab-1:${LEAF_A}`]: { agentType: 'unknown' } } }) + + guard() + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('command') + }) + + it('survives legacy layout leaf ids that are not stable pane uuids', async () => { + setState({ terminalLayoutsByTabId: { 'tab-1': { ptyIdsByLeafId: { leaf: 'pty-a' } } } }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(visibleRequest()).toMatchObject({ copyKind: 'command' }) + expect(onClose).not.toHaveBeenCalled() + }) + + // Why: makePaneKey throws on a tab id containing ':'; the dialog must never be the + // reason a close silently stops happening. + it('closes rather than wedging when the copy-kind lookup throws', async () => { + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + ptyIdsByTabId: { 'tab:1': ['pty-a'] }, + terminalLayoutsByTabId: { 'tab:1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a' } } }, + agentStatusByPaneKey: {} + }) + const onClose = vi.fn() + + guardRunningTerminalClose({ terminalTabId: 'tab:1', tabLabel: 'weird', onClose }) + await settleProbe() + + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab:1', copyKind: 'command' }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('closes rather than wedging when raising the confirmation throws', async () => { + const requestSpy = vi + .spyOn(useRunningTerminalCloseConfirmStore.getState(), 'requestRunningTerminalCloseConfirm') + .mockImplementation(() => { + throw new Error('subscriber blew up') + }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + requestSpy.mockRestore() + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + // Why: a remote inspect can take its full 15s RPC timeout. Closing at 4s would kill a + // running remote command with no prompt — the exact failure this guard exists to stop. + it('prompts instead of closing when a wedged remote probe never settles', async () => { + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockReturnValue(new Promise(() => {})) + const onClose = vi.fn() + + guard(onClose) + expect(onClose).not.toHaveBeenCalled() + + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + + expect(onClose).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1', tabLabel: 'npm run dev' }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('treats every pane as a candidate when picking the copy for a timed-out probe', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } } + }) + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockReturnValue(new Promise(() => {})) + + guard() + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + + expect(visibleRequest()?.copyKind).toBe('agent') + }) + + it('closes rather than wedging when the timed-out prompt throws', async () => { + const requestSpy = vi + .spyOn(useRunningTerminalCloseConfirmStore.getState(), 'requestRunningTerminalCloseConfirm') + .mockImplementation(() => { + throw new Error('subscriber blew up') + }) + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockReturnValue(new Promise(() => {})) + const onClose = vi.fn() + + guard(onClose) + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + requestSpy.mockRestore() + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('ignores a slow probe that resolves after the timeout already prompted', async () => { + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'sleep', + hasChildProcesses: true + }) + const onClose = vi.fn() + + guard(onClose) + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + await settleProbe() + + expect(onClose).not.toHaveBeenCalled() + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + // Why: an SSH drop zeroes ptyIdsByTabId while the layout still names the pane. The stale + // binding is probed, that probe fails on the dead link, and the close falls open — so a + // reconnecting tab stays closable instead of being blocked behind a prompt for a pty + // nobody can reach. Documented so the behavior is a decision, not an accident. + it('closes a reconnecting ssh tab whose pty ids were already zeroed', async () => { + setState({ ptyIdsByTabId: { 'tab-1': [] } }) + inspectRuntimeTerminalProcessMock.mockRejectedValue(new Error('ssh_disconnected')) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal/running-terminal-close-guard.ts b/src/renderer/src/components/terminal/running-terminal-close-guard.ts new file mode 100644 index 00000000000..73f63fc85f7 --- /dev/null +++ b/src/renderer/src/components/terminal/running-terminal-close-guard.ts @@ -0,0 +1,154 @@ +import { useAppStore } from '@/store' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' +import type { TerminalTabCloseReason } from '@/store/slices/terminal-tab-retirement' +import type { AppState } from '@/store/types' +import { resolveBusyPtyCloseCopyKind } from './terminal-close-copy-kind' + +export type RunningTerminalCloseGuardOptions = { + force?: boolean + rejectPinned?: boolean + reason?: TerminalTabCloseReason + hostCloseReason?: TerminalTabCloseReason + lifecyclePtyId?: string + skipRunningProcessConfirm?: boolean +} + +/** Upper bound on how long a close may wait on the probe before it asks instead. A remote + * inspect RPC can hang for its full 15s timeout, and an X button that looks dead for 15s + * is the same class of bug as one that never asks — but an unanswered probe is not + * evidence of an idle shell, so the timeout raises the prompt rather than killing a + * possibly-running remote command (#10142). */ +export const RUNNING_CLOSE_PROBE_TIMEOUT_MS = 4_000 + +/** Whether this close is an interactive user action that should stop and ask before + * killing a live child process. Lifecycle echoes, bulk closes, CLI/RPC closes and the + * post-confirmation re-entry are all excluded. */ +export function shouldConfirmRunningTerminalClose( + options?: RunningTerminalCloseGuardOptions +): boolean { + if (options?.force === true || options?.rejectPinned === true) { + return false + } + if (options?.skipRunningProcessConfirm === true || options?.lifecyclePtyId !== undefined) { + return false + } + const isUserReason = (reason: TerminalTabCloseReason | undefined): boolean => + reason === undefined || reason === 'user' + return isUserReason(options?.reason) && isUserReason(options?.hostCloseReason) +} + +/** Every PTY the tab could still own. `ptyIdsByTabId` is the liveness map the rest of the + * app reads, but a mounting pane is bound into the layout before the map catches up, and + * the store's own teardown collector unions both for exactly that reason — reading only + * the map would let a close slip through the window with no prompt. A stale id costs + * nothing: its probe fails and the guard falls open. */ +function collectTabPtyIds( + state: Pick, + terminalTabId: string +): string[] { + const ptyIds = new Set() + for (const ptyId of state.ptyIdsByTabId?.[terminalTabId] ?? []) { + if (ptyId) { + ptyIds.add(ptyId) + } + } + const ptyIdsByLeafId = state.terminalLayoutsByTabId?.[terminalTabId]?.ptyIdsByLeafId ?? {} + for (const ptyId of Object.values(ptyIdsByLeafId)) { + if (typeof ptyId === 'string' && ptyId) { + ptyIds.add(ptyId) + } + } + return [...ptyIds] +} + +/** + * Routes an interactive terminal-tab close through the running-process confirmation. + * Closes immediately when nothing is running, so idle tabs keep today's behavior. + */ +export function guardRunningTerminalClose(params: { + terminalTabId: string + tabLabel: string + onClose: () => void + onCancel?: () => void +}): void { + const { terminalTabId, tabLabel, onClose, onCancel } = params + const state = useAppStore.getState() + const settings = state.settings + const ptyIds = collectTabPtyIds(state, terminalTabId) + // Why: no PTY at all means there is nothing to probe (parked/hibernated tab, or a + // teardown that already cleared both maps), and the opt-out setting means the answer is + // already known. Both keep the close fully synchronous. + if (ptyIds.length === 0 || settings?.skipCloseTerminalWithRunningProcessConfirm === true) { + onClose() + return + } + + // Why: the timeout, the probe result and the error path race to decide this close, so the + // first one to land owns it instead of trusting those races to stay mutually exclusive. + let decided = false + const closeNow = (): void => { + if (decided) { + return + } + decided = true + onClose() + } + const confirmClose = (busyPtyIds: readonly string[]): void => { + if (decided) { + return + } + const copyKind = resolveBusyPtyCloseCopyKind(terminalTabId, busyPtyIds) + useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({ + terminalTabId, + tabLabel, + copyKind, + onConfirm: onClose, + ...(onCancel ? { onCancel } : {}) + }) + // Why: only once the prompt is actually up — if either call above throws, the close must + // still be free to fall through and happen. + decided = true + } + + const probeTimeout = setTimeout(() => { + try { + // Why: a probe that has not answered yet is unknown, not idle. Ask, treating every pty + // as a candidate, so a degraded relay costs a click instead of a killed remote command. + confirmClose(ptyIds) + } catch { + closeNow() + } + }, RUNNING_CLOSE_PROBE_TIMEOUT_MS) + + void Promise.allSettled(ptyIds.map((ptyId) => inspectRuntimeTerminalProcess(settings, ptyId))) + .then((results) => { + clearTimeout(probeTimeout) + if (decided) { + return + } + // Why: fail open on an *answered* probe, matching the Cmd+W pane path — a rejection + // (wedged relay, legacy provider) or a stale remote handle is not evidence of a live + // child, and a close button that silently does nothing is worse than closing a busy tab. + const busyPtyIds = ptyIds.filter((_, index) => { + const result = results[index] + return ( + result?.status === 'fulfilled' && + result.value.hasChildProcesses && + result.value.unavailable !== true + ) + }) + if (busyPtyIds.length === 0) { + closeNow() + return + } + confirmClose(busyPtyIds) + }) + // Why: allSettled never rejects, so this only fires when the decision above throws (a + // copy-kind lookup, a store subscriber). Without it the tab would silently never close + // and the user would get no feedback at all; the pane path it replaced had this catch. + .catch(() => { + clearTimeout(probeTimeout) + closeNow() + }) +} diff --git a/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts b/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts new file mode 100644 index 00000000000..3ad4229e233 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts @@ -0,0 +1,131 @@ +/** + * Regression for #10142: the tab X button / middle-click used to bypass the + * running-process close confirmation that Cmd+W enforces. Assertions unchanged + * from the adjudicated repro. + * + * Mouse close path: SortableTab (X onClick / onAuxClick button===1) -> onClose + * -> Terminal.tsx handleCloseTab -> closeTerminalTab() -> running-process guard. + * Keyboard path: Cmd+W -> TerminalPane.handleRequestClosePane -> inspectRuntimeTerminalProcess + * (split panes) or closeTerminalTab's guard (last pane) -> CloseTerminalDialog. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + requestPinnedTabCloseConfirmMock, + getStateMock, + inspectRuntimeTerminalProcessMock, + isWebRuntimeSessionActiveMock, + resolveHostSessionTabIdForWebSessionTabMock +} = vi.hoisted(() => ({ + requestPinnedTabCloseConfirmMock: vi.fn(), + getStateMock: vi.fn(), + inspectRuntimeTerminalProcessMock: vi.fn(), + isWebRuntimeSessionActiveMock: vi.fn(() => false), + resolveHostSessionTabIdForWebSessionTabMock: vi.fn<() => string | null>(() => null) +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: vi.fn(), + closeWebRuntimeSessionTab: vi.fn(), + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock, + isWebTerminalSurfaceTabId: vi.fn(() => false), + toHostSessionTabId: vi.fn((tabId: string) => tabId) +})) + +vi.mock('@/runtime/web-session-tabs-sync', () => ({ + getLatestWebSessionTabsPublicationEpoch: vi.fn(() => 'epoch-1'), + resolveHostSessionTabIdForWebSessionTab: resolveHostSessionTabIdForWebSessionTabMock +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock +})) + +import { closeTerminalTab } from './terminal-tab-actions' + +// A non-pinned terminal tab whose PTY has a live child process (e.g. `sleep 300`). +function stateWithBusyTerminalTab(closeTab: () => void): Record { + return { + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: true }, + tabsByWorktree: { 'wt-1': [{ id: 'tab-busy' }, { id: 'tab-other' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { id: 'tab-busy', entityId: 'tab-busy', contentType: 'terminal', isPinned: false }, + { id: 'tab-other', entityId: 'tab-other', contentType: 'terminal', isPinned: false } + ] + }, + ptyIdsByTabId: { 'tab-busy': ['pty-busy'] }, + terminalLayoutsByTabId: { 'tab-busy': { ptyIdsByLeafId: { leaf: 'pty-busy' } } }, + agentStatusByPaneKey: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-busy', + closeTab, + requestPinnedTabCloseConfirm: requestPinnedTabCloseConfirmMock, + setActiveTab: vi.fn(), + setActiveFile: vi.fn(), + setActiveTabType: vi.fn(), + setActiveBrowserTab: vi.fn(), + setActiveWorktree: vi.fn() + } +} + +describe('#10142 close confirmation policy is the same for keyboard and mouse', () => { + const closeTab = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + getStateMock.mockReturnValue(stateWithBusyTerminalTab(closeTab)) + isWebRuntimeSessionActiveMock.mockReturnValue(false) + resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue(null) + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'sleep', + hasChildProcesses: true + }) + }) + + // Control: the keyboard entry point does probe for running children. + it('keyboard Cmd+W path probes for running child processes before closing', () => { + const source = readFileSync(join(__dirname, '../terminal-pane/TerminalPane.tsx'), 'utf8') + const handler = source.slice(source.indexOf('const handleRequestClosePane')) + expect(handler.slice(0, handler.indexOf('useImperativeHandle'))).toContain( + 'inspectRuntimeTerminalProcess' + ) + }) + + // Control: the harness does observe a guard when one exists — pinning blocks the same mouse close. + it('mouse close routes a pinned tab through its confirmation guard', () => { + const state = stateWithBusyTerminalTab(closeTab) + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + + closeTerminalTab('tab-busy') + + expect(requestPinnedTabCloseConfirmMock).toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + }) + + it('mouse close (X button / middle-click) consults the running-process probe', async () => { + closeTerminalTab('tab-busy') + await Promise.resolve() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalled() + }) + + it('mouse close (X button / middle-click) does not drop a busy tab without confirmation', async () => { + closeTerminalTab('tab-busy') + await Promise.resolve() + + expect(closeTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts b/src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts new file mode 100644 index 00000000000..960eaaa8c9c --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts @@ -0,0 +1,67 @@ +/** + * The keyboard (pane-scoped) and tab-strip (pty-scoped) close prompts must word the same + * close the same way, so both resolve their copy through this module (#10142). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getStateMock } = vi.hoisted(() => ({ getStateMock: vi.fn() })) + +vi.mock('@/store', () => ({ useAppStore: { getState: getStateMock } })) + +import { resolveBusyPtyCloseCopyKind, resolveLeafCloseCopyKind } from './terminal-close-copy-kind' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +function setState(overrides: Record = {}): void { + getStateMock.mockReturnValue({ + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } }, + ...overrides + }) +} + +describe('terminal close copy kind', () => { + beforeEach(() => { + vi.clearAllMocks() + setState() + }) + + it('agrees between the pane lookup and the busy-pty lookup for the same pane', () => { + expect(resolveLeafCloseCopyKind('tab-1', LEAF_B)).toBe('agent') + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-b'])).toBe('agent') + + expect(resolveLeafCloseCopyKind('tab-1', LEAF_A)).toBe('command') + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-a'])).toBe('command') + }) + + it('prefers the agent copy when a split has both busy', () => { + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-a', 'pty-b'])).toBe('agent') + }) + + it.each([ + ['a missing leaf id', undefined], + ['a null leaf id', null], + ['a legacy non-uuid leaf id', 'leaf'] + ])('falls back to the command copy for %s', (_label, leafId) => { + expect(resolveLeafCloseCopyKind('tab-1', leafId)).toBe('command') + }) + + it('never throws on a tab id that makePaneKey would reject', () => { + setState({ + terminalLayoutsByTabId: { 'tab:1': { ptyIdsByLeafId: { [LEAF_B]: 'pty-b' } } } + }) + + expect(resolveLeafCloseCopyKind('tab:1', LEAF_B)).toBe('command') + expect(resolveBusyPtyCloseCopyKind('tab:1', ['pty-b'])).toBe('command') + }) + + it('ignores an unknown agent type', () => { + setState({ agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'unknown' } } }) + + expect(resolveLeafCloseCopyKind('tab-1', LEAF_B)).toBe('command') + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-b'])).toBe('command') + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-close-copy-kind.ts b/src/renderer/src/components/terminal/terminal-close-copy-kind.ts new file mode 100644 index 00000000000..e012987ba4d --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-close-copy-kind.ts @@ -0,0 +1,38 @@ +import { useAppStore } from '@/store' +import type { CloseTerminalDialogCopyKind } from '../terminal-pane/CloseTerminalDialog' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' + +/** + * Single source of truth for "is this pane an agent?" in the close confirmation, so the + * keyboard (pane-scoped) and tab-strip (pty-scoped) prompts cannot word the same close + * differently. Each caller resolves its own leaf id; only the policy is shared. + */ +export function resolveLeafCloseCopyKind( + tabId: string, + leafId: string | null | undefined +): CloseTerminalDialogCopyKind { + // Why: legacy layouts and mid-attach panes carry non-UUID or missing leaf ids, and + // makePaneKey throws on those — a dialog must never be the thing that breaks a close. + if (!leafId || !isTerminalLeafId(leafId) || !tabId || tabId.includes(':')) { + return 'command' + } + const agentStatusByPaneKey = useAppStore.getState().agentStatusByPaneKey ?? {} + const agentType = agentStatusByPaneKey[makePaneKey(tabId, leafId)]?.agentType + return agentType && agentType !== 'unknown' ? 'agent' : 'command' +} + +/** Copy for a whole-tab close, given the PTYs that reported a live child. Agent panes win + * in a mixed split: stopping an agent mid-task is the costlier surprise. */ +export function resolveBusyPtyCloseCopyKind( + tabId: string, + busyPtyIds: readonly string[] +): CloseTerminalDialogCopyKind { + const ptyIdsByLeafId = + useAppStore.getState().terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId ?? {} + for (const [leafId, ptyId] of Object.entries(ptyIdsByLeafId)) { + if (busyPtyIds.includes(ptyId) && resolveLeafCloseCopyKind(tabId, leafId) === 'agent') { + return 'agent' + } + } + return 'command' +} diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 3dd2762f7f3..55934e7ae88 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -9,11 +9,20 @@ import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync' import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' -import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel, + shouldConfirmPinnedTabClose +} from '@/store/pinned-tab-close-guard' import type { TerminalTabCloseReason, TerminalTabRetirementPlan } from '@/store/slices/terminal-tab-retirement' +import { + guardRunningTerminalClose, + shouldConfirmRunningTerminalClose +} from './running-terminal-close-guard' import { closeLocalTerminalTabState } from './close-local-terminal-tab-state' import { getTerminalIncarnationHandle } from './terminal-close-incarnation' import { @@ -25,20 +34,6 @@ import { export type { PrecomputedTerminalCloseState } from './terminal-close-target' export { closeOtherTerminalTabs, closeTerminalTabsToRight } from './terminal-tab-bulk-actions' -type TerminalTabActionState = ReturnType - -function isPinnedVisibleTab( - state: TerminalTabActionState, - worktreeId: string, - visibleId: string -): boolean { - return ( - (state.unifiedTabsByWorktree?.[worktreeId] ?? []).some( - (tab) => (tab.id === visibleId || tab.entityId === visibleId) && tab.isPinned - ) ?? false - ) -} - export function closeTerminalTab( tabId: string, options?: { @@ -51,6 +46,9 @@ export function closeTerminalTab( hostCloseReason?: TerminalTabCloseReason /** PTY whose lifecycle event initiated the host close. */ lifecyclePtyId?: string + /** Set by callers that must never raise a modal (bulk closes, CLI/RPC, lifecycle + * fallbacks) and by the re-entry that runs once the user confirmed. */ + skipRunningProcessConfirm?: boolean captureRecentlyClosed?: boolean localPtyTeardownOwnedExternally?: boolean precomputedRetirementPlan?: TerminalTabRetirementPlan @@ -82,7 +80,7 @@ export function closeTerminalTab( if ( options?.reason !== 'pty-exit' && !options?.force && - isPinnedVisibleTab(state, owningWorktreeId, terminalTabId) + isUnifiedTabPinned(state, owningWorktreeId, terminalTabId) ) { // Why: background lifecycle callers cannot safely wait on a modal whose // owner may be unattended; reject pinned tabs without bypassing the guard. @@ -90,10 +88,30 @@ export function closeTerminalTab( options.onCancel?.() return } - guardPinnedTabClose({ - isPinned: true, + // Why: the pin prompt supersedes the running-process one only when it actually + // appears. With `confirmClosePinnedTab` off it says nothing, so fall through and let + // a busy pinned tab still get asked — Cmd+W did exactly that before #10142. + if (shouldConfirmPinnedTabClose(state)) { + guardPinnedTabClose({ + isPinned: true, + tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId), + onClose: () => closeTerminalTab(tabId, { ...options, force: true }), + ...(options?.onCancel ? { onCancel: options.onCancel } : {}) + }) + return + } + } + + // Why: the X button, middle-click and the tab menu used to skip the running-process + // prompt that Cmd+W enforced (#10142). Guarding here — above the web-runtime branch so + // host-backed tabs are covered too — gives every close path one shared policy. + if (shouldConfirmRunningTerminalClose(options)) { + guardRunningTerminalClose({ + terminalTabId, tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId), - onClose: () => closeTerminalTab(tabId, { ...options, force: true }), + // Why: re-enter instead of continuing inline so pinned/route/precomputed state is + // re-validated against fresh state after an arbitrarily long dialog. + onClose: () => closeTerminalTab(tabId, { ...options, skipRunningProcessConfirm: true }), ...(options?.onCancel ? { onCancel: options.onCancel } : {}) }) return diff --git a/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts b/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts new file mode 100644 index 00000000000..066c5795ef9 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts @@ -0,0 +1,263 @@ +/** + * Which `closeTerminalTab` callers reach the running-process confirmation (#10142). + * Kept out of terminal-tab-actions.test.ts, which is already at its max-lines budget. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + closeWebRuntimeSessionTabMock, + getStateMock, + inspectRuntimeTerminalProcessMock, + isWebRuntimeSessionActiveMock, + requestPinnedTabCloseConfirmMock, + resolveHostSessionTabIdForWebSessionTabMock +} = vi.hoisted(() => ({ + closeWebRuntimeSessionTabMock: vi.fn(), + getStateMock: vi.fn(), + inspectRuntimeTerminalProcessMock: vi.fn(), + isWebRuntimeSessionActiveMock: vi.fn(() => false), + requestPinnedTabCloseConfirmMock: vi.fn(), + resolveHostSessionTabIdForWebSessionTabMock: vi.fn<() => string | null>(() => null) +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: vi.fn(), + closeWebRuntimeSessionTab: closeWebRuntimeSessionTabMock, + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock, + isWebTerminalSurfaceTabId: vi.fn(() => false), + toHostSessionTabId: vi.fn((tabId: string) => tabId) +})) + +vi.mock('@/runtime/web-session-tabs-sync', () => ({ + getLatestWebSessionTabsPublicationEpoch: vi.fn(() => 'epoch-1'), + resolveHostSessionTabIdForWebSessionTab: resolveHostSessionTabIdForWebSessionTabMock +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock +})) + +import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' +import { closeTerminalTab } from './terminal-tab-actions' + +const LEAF = '33333333-3333-4333-8333-333333333333' + +const closeTab = vi.fn() + +function busyTabState(overrides: Record = {}): Record { + return { + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: true }, + tabsByWorktree: { 'wt-1': [{ id: 'tab-busy' }, { id: 'tab-other' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'tab-busy', + entityId: 'tab-busy', + contentType: 'terminal', + isPinned: false, + label: 'dev server' + }, + { id: 'tab-other', entityId: 'tab-other', contentType: 'terminal', isPinned: false } + ] + }, + ptyIdsByTabId: { 'tab-busy': ['pty-busy'] }, + terminalLayoutsByTabId: { 'tab-busy': { ptyIdsByLeafId: { [LEAF]: 'pty-busy' } } }, + agentStatusByPaneKey: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-busy', + closeTab, + requestPinnedTabCloseConfirm: requestPinnedTabCloseConfirmMock, + setActiveTab: vi.fn(), + setActiveFile: vi.fn(), + setActiveTabType: vi.fn(), + setActiveBrowserTab: vi.fn(), + setActiveWorktree: vi.fn(), + ...overrides + } +} + +function visibleRequest() { + return useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm +} + +async function settleProbe(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('closeTerminalTab running-process confirmation', () => { + beforeEach(() => { + vi.clearAllMocks() + getStateMock.mockReturnValue(busyTabState()) + isWebRuntimeSessionActiveMock.mockReturnValue(false) + resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue(null) + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'npm', + hasChildProcesses: true + }) + }) + + afterEach(() => { + const store = useRunningTerminalCloseConfirmStore.getState() + while (visibleRequest()) { + store.dismissRunningTerminalClose() + } + }) + + it('closes a busy tab only after the confirmation is accepted', async () => { + closeTerminalTab('tab-busy') + await settleProbe() + + expect(closeTab).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ + terminalTabId: 'tab-busy', + tabLabel: 'dev server', + copyKind: 'command' + }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + it('keeps the tab and runs onCancel when the confirmation is dismissed', async () => { + const onCancel = vi.fn() + const onClosed = vi.fn() + + closeTerminalTab('tab-busy', { onCancel, onClosed }) + await settleProbe() + useRunningTerminalCloseConfirmStore.getState().dismissRunningTerminalClose() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onClosed).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + }) + + it('still reports the close through onClosed after the confirmation', async () => { + const onClosed = vi.fn() + + closeTerminalTab('tab-busy', { onClosed }) + await settleProbe() + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(onClosed).toHaveBeenCalledTimes(1) + }) + + it('stays fully synchronous for a tab with no live pty', () => { + getStateMock.mockReturnValue(busyTabState({ ptyIdsByTabId: {}, terminalLayoutsByTabId: {} })) + + closeTerminalTab('tab-busy') + + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + it.each([ + ['force (post-confirmation re-entry)', { force: true }], + ['rejectPinned (CLI tab close request)', { rejectPinned: true }], + ['skipRunningProcessConfirm (bulk / CLI)', { skipRunningProcessConfirm: true }], + ['reason pty-exit', { reason: 'pty-exit' as const }], + ['reason cleanup', { reason: 'cleanup' as const }], + ['hostCloseReason pty-exit', { hostCloseReason: 'pty-exit' as const }], + ['lifecyclePtyId', { lifecyclePtyId: 'pty-busy' }] + ])('never probes for a close carrying %s', (_label, options) => { + closeTerminalTab('tab-busy', options) + + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + }) + + it('shows only the pinned dialog for a pinned busy tab, and its confirm does not probe', () => { + const state = busyTabState() + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + + closeTerminalTab('tab-busy') + + expect(requestPinnedTabCloseConfirmMock).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + + const onConfirm = requestPinnedTabCloseConfirmMock.mock.calls[0]![0].onConfirm as () => void + onConfirm() + + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + // Why: the pin prompt supersedes this one only when it actually appears. With the pin + // confirmation off it says nothing, and the running command must still be announced. + it('still asks about a running command on a pinned tab when pin confirmation is off', async () => { + const state = busyTabState({ + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: false } + }) + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + + closeTerminalTab('tab-busy') + await settleProbe() + + expect(requestPinnedTabCloseConfirmMock).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-busy' }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + it('rejects a pinned tab for a CLI close even when pin confirmation is off', () => { + const state = busyTabState({ + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: false } + }) + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + const onCancel = vi.fn() + + closeTerminalTab('tab-busy', { rejectPinned: true, onCancel }) + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + }) + + it('confirms before telling a paired host to close its busy tab', async () => { + isWebRuntimeSessionActiveMock.mockReturnValue(true) + getStateMock.mockReturnValue( + busyTabState({ settings: { activeRuntimeEnvironmentId: 'web-runtime' } }) + ) + + closeTerminalTab('tab-busy') + await settleProbe() + + expect(closeWebRuntimeSessionTabMock).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(1) + }) + + it('picks the agent copy for a busy agent pane', async () => { + getStateMock.mockReturnValue( + busyTabState({ agentStatusByPaneKey: { [`tab-busy:${LEAF}`]: { agentType: 'claude' } } }) + ) + + closeTerminalTab('tab-busy') + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('agent') + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index e59b6db6b95..c13cf20057a 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -3382,7 +3382,10 @@ describe('useIpcEvents browser tab close routing', () => { closeTerminalListenerRef.current?.({ tabId: 'terminal-1' }) - expect(closeTerminalTabMock).toHaveBeenCalledWith('terminal-1') + // The CLI/RPC caller is answered immediately, so this close must never raise a modal. + expect(closeTerminalTabMock).toHaveBeenCalledWith('terminal-1', { + skipRunningProcessConfirm: true + }) }) it('acknowledges whole-tab close only after the fresh session is durably persisted', async () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index be6a5e4b2d3..35c48344e63 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1965,7 +1965,8 @@ export function useIpcEvents(): void { const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId } window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail })) } else { - closeTerminalTab(tabId) + // Why: the CLI/RPC caller is answered immediately, so it cannot wait on a modal. + closeTerminalTab(tabId, { skipRunningProcessConfirm: true }) } }) ) diff --git a/src/renderer/src/store/pinned-tab-close-guard.ts b/src/renderer/src/store/pinned-tab-close-guard.ts index 9603abd60f1..75a7db4fcc8 100644 --- a/src/renderer/src/store/pinned-tab-close-guard.ts +++ b/src/renderer/src/store/pinned-tab-close-guard.ts @@ -23,6 +23,13 @@ export function isUnifiedTabPinned(state: AppState, worktreeId: string, tabId: s ) } +/** Whether a pinned close will actually raise the pin dialog. Callers that let the pin + * prompt supersede another confirmation must know this: with the setting off the pin + * has nothing to say, so it must not swallow the other prompt (#10142). */ +export function shouldConfirmPinnedTabClose(state: AppState): boolean { + return state.settings?.confirmClosePinnedTab ?? true +} + /** Routes a pinned-tab close attempt through the confirmation dialog when the * setting is on. Non-pinned tabs (and pinned tabs when the setting is off) * close immediately. Keeping every close path behind this single helper is why @@ -40,8 +47,7 @@ export function guardPinnedTabClose(params: { } const state = useAppStore.getState() - const shouldConfirm = state.settings?.confirmClosePinnedTab ?? true - if (!shouldConfirm) { + if (!shouldConfirmPinnedTabClose(state)) { onClose() return } diff --git a/src/renderer/src/store/running-terminal-close-confirm.test.ts b/src/renderer/src/store/running-terminal-close-confirm.test.ts new file mode 100644 index 00000000000..b597923a148 --- /dev/null +++ b/src/renderer/src/store/running-terminal-close-confirm.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useRunningTerminalCloseConfirmStore } from './running-terminal-close-confirm' + +// The store rejects a second action within 350 ms of revealing a queued request, so these +// tests drive a clock rather than letting wall time decide whether an action lands. +let clock = 1_000 + +function advancePastGuard(): void { + clock += 400 +} + +function drainRequests(): void { + const store = useRunningTerminalCloseConfirmStore.getState() + while (useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm) { + advancePastGuard() + store.dismissRunningTerminalClose() + } +} + +function request(terminalTabId: string, onConfirm: () => void = vi.fn(), onCancel?: () => void) { + return { + terminalTabId, + tabLabel: terminalTabId, + copyKind: 'command' as const, + onConfirm, + ...(onCancel ? { onCancel } : {}) + } +} + +describe('running terminal close confirmation store', () => { + beforeEach(() => { + // Monotonic across tests: the store is a singleton, so winding the clock back would + // leave a previous test's guard deadline in the future and block every action. + clock += 10_000 + vi.spyOn(Date, 'now').mockImplementation(() => clock) + }) + + afterEach(() => { + drainRequests() + vi.mocked(Date.now).mockRestore() + }) + + it('shows the first request and queues the next one', () => { + const first = vi.fn() + const second = vi.fn() + const store = useRunningTerminalCloseConfirmStore.getState() + + store.requestRunningTerminalCloseConfirm(request('tab-1', first)) + store.requestRunningTerminalCloseConfirm(request('tab-2', second)) + + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-1') + + store.confirmRunningTerminalClose() + + expect(first).toHaveBeenCalledTimes(1) + expect(second).not.toHaveBeenCalled() + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + }) + + it('shows one prompt for a repeat request but still resolves both closes', () => { + const first = vi.fn() + const duplicate = vi.fn() + const store = useRunningTerminalCloseConfirmStore.getState() + + store.requestRunningTerminalCloseConfirm(request('tab-1', first)) + store.requestRunningTerminalCloseConfirm(request('tab-1', duplicate)) + store.confirmRunningTerminalClose() + + expect(first).toHaveBeenCalledTimes(1) + expect(duplicate).toHaveBeenCalledTimes(1) + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('cancels both callers when a folded repeat request is dismissed', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const firstCancel = vi.fn() + const duplicateCancel = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1', vi.fn(), firstCancel)) + store.requestRunningTerminalCloseConfirm(request('tab-1', vi.fn(), duplicateCancel)) + store.dismissRunningTerminalClose() + + expect(firstCancel).toHaveBeenCalledTimes(1) + expect(duplicateCancel).toHaveBeenCalledTimes(1) + }) + + it('folds a repeat request into the one already waiting in the queue', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const queued = vi.fn() + const duplicate = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1')) + store.requestRunningTerminalCloseConfirm(request('tab-2', queued)) + store.requestRunningTerminalCloseConfirm(request('tab-2', duplicate)) + + store.confirmRunningTerminalClose() + advancePastGuard() + store.confirmRunningTerminalClose() + + expect(queued).toHaveBeenCalledTimes(1) + expect(duplicate).toHaveBeenCalledTimes(1) + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('confirms every pending request once the user opts out of the prompt', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const visible = vi.fn() + const queued = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1', visible)) + store.requestRunningTerminalCloseConfirm(request('tab-2', queued)) + store.confirmAllRunningTerminalCloses() + + expect(visible).toHaveBeenCalledTimes(1) + expect(queued).toHaveBeenCalledTimes(1) + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('runs onCancel on dismiss and never the close', () => { + const onConfirm = vi.fn() + const onCancel = vi.fn() + const store = useRunningTerminalCloseConfirmStore.getState() + + store.requestRunningTerminalCloseConfirm(request('tab-1', onConfirm, onCancel)) + store.dismissRunningTerminalClose() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onConfirm).not.toHaveBeenCalled() + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('is inert when there is nothing to confirm', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + + expect(() => store.confirmRunningTerminalClose()).not.toThrow() + expect(() => store.dismissRunningTerminalClose()).not.toThrow() + }) + + it('lets a re-entrant close from onConfirm queue behind the next request', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const reentrant = vi.fn() + const second = vi.fn() + + store.requestRunningTerminalCloseConfirm( + request('tab-1', () => { + // Why: the real onConfirm re-enters closeTerminalTab, which may request again. + store.requestRunningTerminalCloseConfirm(request('tab-3', reentrant)) + }) + ) + store.requestRunningTerminalCloseConfirm(request('tab-2', second)) + store.confirmRunningTerminalClose() + + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + + advancePastGuard() + store.confirmRunningTerminalClose() + expect(second).toHaveBeenCalledTimes(1) + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-3') + + advancePastGuard() + store.confirmRunningTerminalClose() + expect(reentrant).toHaveBeenCalledTimes(1) + }) + + // Why: a queued request replaces the visible one in place. Without this window, the second + // click of a double-click aimed at one tab lands on the next tab's prompt and kills a + // running process the user never saw asked about — the bug class this PR exists to close. + it('ignores a second action that lands on the freshly revealed request', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const first = vi.fn() + const second = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1', first)) + store.requestRunningTerminalCloseConfirm(request('tab-2', second)) + + store.confirmRunningTerminalClose() + store.confirmRunningTerminalClose() + + expect(first).toHaveBeenCalledTimes(1) + expect(second).not.toHaveBeenCalled() + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + + advancePastGuard() + store.confirmRunningTerminalClose() + expect(second).toHaveBeenCalledTimes(1) + }) + + it('also holds off a dismiss that lands on the freshly revealed request', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const secondCancel = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1')) + store.requestRunningTerminalCloseConfirm(request('tab-2', vi.fn(), secondCancel)) + + store.confirmRunningTerminalClose() + store.dismissRunningTerminalClose() + + expect(secondCancel).not.toHaveBeenCalled() + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + }) + + // Why: emptying the queue leaves nothing to mis-click, so the opt-out must not be delayed. + it('arms no guard when the last request leaves the queue empty', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const onConfirm = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1')) + store.confirmRunningTerminalClose() + store.requestRunningTerminalCloseConfirm(request('tab-2', onConfirm)) + store.confirmRunningTerminalClose() + + expect(onConfirm).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/store/running-terminal-close-confirm.ts b/src/renderer/src/store/running-terminal-close-confirm.ts new file mode 100644 index 00000000000..618d30c98c5 --- /dev/null +++ b/src/renderer/src/store/running-terminal-close-confirm.ts @@ -0,0 +1,132 @@ +import { create } from 'zustand' +import type { CloseTerminalDialogCopyKind } from '@/components/terminal-pane/CloseTerminalDialog' + +/** A pending confirmation for closing a terminal tab whose shell still has a + * running child process. `onConfirm` performs the original close. */ +export type RunningTerminalCloseConfirmRequest = { + terminalTabId: string + tabLabel: string + copyKind: CloseTerminalDialogCopyKind + onConfirm: () => void + onCancel?: () => void +} + +export type RunningTerminalCloseConfirmState = { + runningTerminalCloseConfirm: RunningTerminalCloseConfirmRequest | null + requestRunningTerminalCloseConfirm: (request: RunningTerminalCloseConfirmRequest) => void + confirmRunningTerminalClose: () => void + /** Accepts the visible request and every queued one. Used when the user ticks "don't ask + * again": a prompt they just opted out of must not still be waiting behind this one. */ + confirmAllRunningTerminalCloses: () => void + dismissRunningTerminalClose: () => void +} + +/** Folds a duplicate request for the same tab into the pending one instead of dropping it, + * so two surfaces closing the same tab both get their callback. */ +function mergeRequests( + pending: RunningTerminalCloseConfirmRequest, + duplicate: RunningTerminalCloseConfirmRequest +): RunningTerminalCloseConfirmRequest { + return { + ...pending, + onConfirm: () => { + pending.onConfirm() + duplicate.onConfirm() + }, + onCancel: () => { + pending.onCancel?.() + duplicate.onCancel?.() + } + } +} + +// Why a standalone store instead of an AppState slice (which is what the sibling +// pinned-tab confirmation uses): the request is raised from closeTerminalTab, a plain +// module whose unit fixtures build partial app-state objects, so dispatching through +// useAppStore.getState() would throw there. Nothing outside the dialog reads this state, +// so the AppState coupling would buy nothing. +export const useRunningTerminalCloseConfirmStore = create()(( + set, + get +) => { + const queuedRequests: RunningTerminalCloseConfirmRequest[] = [] + // Why: a queued request replaces the visible one in place, so a double-click or held + // Enter meant for the tab the user was looking at would land on the next tab's prompt and + // kill a second running process unseen. Matches the sibling pinned-tab confirmation. + const INTER_REQUEST_ACTION_GUARD_MS = 350 + let nextRequestActionAllowedAt = 0 + + /** Reveals the next queued request, and reports whether one took the visible slot. */ + const advanceRequest = (): boolean => { + const next = queuedRequests.shift() ?? null + set({ runningTerminalCloseConfirm: next }) + return next !== null + } + + const guardNextAction = (revealedNextRequest: boolean): void => { + if (revealedNextRequest) { + nextRequestActionAllowedAt = Date.now() + INTER_REQUEST_ACTION_GUARD_MS + } + } + + return { + runningTerminalCloseConfirm: null, + + requestRunningTerminalCloseConfirm: (request) => { + const visible = get().runningTerminalCloseConfirm + // Why: the probe is async, so a second click on the same tab arrives before the + // dialog opens. One prompt, but both closes still resolve. + if (visible?.terminalTabId === request.terminalTabId) { + set({ runningTerminalCloseConfirm: mergeRequests(visible, request) }) + return + } + const queuedIndex = queuedRequests.findIndex( + (queued) => queued.terminalTabId === request.terminalTabId + ) + if (queuedIndex >= 0) { + queuedRequests[queuedIndex] = mergeRequests(queuedRequests[queuedIndex]!, request) + return + } + if (visible) { + // Why: closing two busy tabs in quick succession must not strand the second + // tab's close callback behind a replaced request. + queuedRequests.push(request) + return + } + set({ runningTerminalCloseConfirm: request }) + }, + + confirmRunningTerminalClose: () => { + const request = get().runningTerminalCloseConfirm + if (!request || Date.now() < nextRequestActionAllowedAt) { + return + } + // Why: advance before running onConfirm so a re-entrant close queues behind the + // next real request instead of seeing the stale one. + guardNextAction(advanceRequest()) + request.onConfirm() + }, + + confirmAllRunningTerminalCloses: () => { + if (Date.now() < nextRequestActionAllowedAt) { + return + } + const pending = [get().runningTerminalCloseConfirm, ...queuedRequests.splice(0)] + set({ runningTerminalCloseConfirm: null }) + // No guard to arm: the queue is empty, so there is no next prompt to mis-click. + for (const request of pending) { + request?.onConfirm() + } + }, + + dismissRunningTerminalClose: () => { + const request = get().runningTerminalCloseConfirm + if (!request || Date.now() < nextRequestActionAllowedAt) { + return + } + guardNextAction(advanceRequest()) + // Why: callers such as the tab-group model resume their own cleanup on cancel. + request.onCancel?.() + } + } +}) diff --git a/tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts b/tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts new file mode 100644 index 00000000000..c854c8ba798 --- /dev/null +++ b/tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts @@ -0,0 +1,101 @@ +/** + * #10142 follow-ups to the X-button regression: middle-click prompts too, confirming + * actually closes, and Cmd+W raises exactly one dialog (the pane path delegates the + * last-pane close to closeTerminalTab instead of probing a second time). + */ +import { test, expect } from './helpers/orca-app' +import type { Page } from '@stablyai/playwright-test' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveTabId, + ensureTerminalVisible +} from './helpers/store' +import { + execInTerminal, + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' + +const SORTABLE_TAB = '[data-testid="sortable-tab"]' + +function closeDialogTitle(page: Page) { + return page.getByText(/Stop running command\?|Stop this agent\?/) +} + +async function startBusyTerminal(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + const hasPaneManager = await waitForActiveTerminalManager(page, 30_000) + .then(() => true) + .catch(() => false) + test.skip(!hasPaneManager, 'Electron automation never mounted the live TerminalPane manager.') + await waitForPaneCount(page, 1, 30_000) + + const ptyId = await waitForActivePanePtyId(page) + await execInTerminal(page, ptyId, 'echo close-confirm-ready') + await waitForTerminalOutput(page, 'close-confirm-ready', 20_000) + await execInTerminal(page, ptyId, 'sleep 300') + // Why: `hasChildProcesses` is already true while macOS's `login` wrapper starts the + // shell, so wait for `sleep` itself or the close legitimately sees an idle terminal. + await expect + .poll( + async () => + (await page.evaluate((id) => window.api.pty.inspectProcess(id), ptyId)).foregroundProcess, + { timeout: 20_000, message: 'sleep 300 never became the foreground process' } + ) + .toBe('sleep') + return (await getActiveTabId(page))! +} + +test.describe.configure({ mode: 'serial' }) + +test('middle-clicking a busy tab prompts, and cancelling keeps the tab', async ({ orcaPage }) => { + test.setTimeout(120_000) + const busyTabId = await startBusyTerminal(orcaPage) + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + const tabsBefore = await orcaPage.locator(SORTABLE_TAB).count() + + await busyTab.click({ button: 'middle' }) + + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Cancel$/ }).click() + await expect(closeDialogTitle(orcaPage)).toBeHidden() + await expect(busyTab).toBeVisible() + expect(await orcaPage.locator(SORTABLE_TAB).count()).toBe(tabsBefore) +}) + +test('confirming the X-button prompt closes the busy tab', async ({ orcaPage }) => { + test.setTimeout(120_000) + const busyTabId = await startBusyTerminal(orcaPage) + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + + await busyTab.hover() + await busyTab.getByRole('button', { name: /^Close tab /i }).click() + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Stop and Close$/ }).click() + + await expect(busyTab).toHaveCount(0, { timeout: 15_000 }) + await expect(closeDialogTitle(orcaPage)).toBeHidden() +}) + +test('Cmd+W on a busy single-pane tab raises exactly one dialog', async ({ orcaPage }) => { + test.setTimeout(120_000) + const busyTabId = await startBusyTerminal(orcaPage) + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Stop and Close$/ }).click() + + await expect(busyTab).toHaveCount(0, { timeout: 15_000 }) + // Why: the pane used to probe and prompt on its own before delegating to + // closeTerminalTab, which now prompts too — a second dialog would mean a double prompt. + await orcaPage.waitForTimeout(1_500) + await expect(closeDialogTitle(orcaPage)).toBeHidden() +}) diff --git a/tests/e2e/terminal-tab-close-running-confirm.spec.ts b/tests/e2e/terminal-tab-close-running-confirm.spec.ts new file mode 100644 index 00000000000..68182eb4798 --- /dev/null +++ b/tests/e2e/terminal-tab-close-running-confirm.spec.ts @@ -0,0 +1,90 @@ +/** + * Regression for #10142: keyboard and mouse enforce the same running-process close + * confirmation. Both halves run against one tab with a live `sleep 300` child: + * 1. Cmd/Ctrl+W -> "Stop running command?" dialog (cancelled, tab survives). + * 2. X click -> the same dialog, and the tab is still there behind it. + */ +import { test, expect } from './helpers/orca-app' +import type { Page } from '@stablyai/playwright-test' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveTabId, + ensureTerminalVisible +} from './helpers/store' +import { + execInTerminal, + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' + +const SORTABLE_TAB = '[data-testid="sortable-tab"]' + +function countRenderedTabs(page: Page): Promise { + return page.locator(SORTABLE_TAB).count() +} + +function closeDialogTitle(page: Page) { + return page.getByText(/Stop running command\?|Stop this agent\?/) +} + +test.describe.configure({ mode: 'serial' }) + +test('the tab X button applies the same running-process confirmation as Cmd+W', async ({ + orcaPage +}) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + const hasPaneManager = await waitForActiveTerminalManager(orcaPage, 30_000) + .then(() => true) + .catch(() => false) + test.skip(!hasPaneManager, 'Electron automation never mounted the live TerminalPane manager.') + await waitForPaneCount(orcaPage, 1, 30_000) + + const ptyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal(orcaPage, ptyId, 'echo repro-10142-ready') + await waitForTerminalOutput(orcaPage, 'repro-10142-ready', 20_000) + await execInTerminal(orcaPage, ptyId, 'sleep 300') + // Only press close once `sleep` is the foreground process; otherwise the probe + // legitimately sees an idle shell and closing is correct. `hasChildProcesses` alone is + // not enough: macOS spawns the shell under `login`, so a still-initialising terminal + // reports a child before `sleep 300` has run. + await expect + .poll( + async () => + (await orcaPage.evaluate((id) => window.api.pty.inspectProcess(id), ptyId)) + .foregroundProcess, + { timeout: 20_000, message: 'sleep 300 never became the foreground process' } + ) + .toBe('sleep') + + const busyTabId = (await getActiveTabId(orcaPage))! + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + + // 1. Keyboard close prompts. + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Cancel$/ }).click() + await expect(closeDialogTitle(orcaPage)).toBeHidden() + await expect(busyTab).toBeVisible() + const tabsBefore = await countRenderedTabs(orcaPage) + + // 2. Same tab, same running child, mouse close. + await busyTab.hover() + await busyTab.getByRole('button', { name: /^Close tab /i }).click() + await orcaPage.waitForTimeout(1_500) + + expect( + { + confirmDialogVisible: await closeDialogTitle(orcaPage).isVisible(), + tabStillPresent: (await countRenderedTabs(orcaPage)) === tabsBefore + }, + 'X-button close must apply the same running-process confirmation as Cmd+W' + ).toEqual({ confirmDialogVisible: true, tabStillPresent: true }) +}) From 0927b9c156a86e9610cd00538d057b17a0c9f4fe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:48:37 -0700 Subject: [PATCH 033/101] fix(gitlab): load pipeline job traces in the Checks side panel (#7732) (#12266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(repro): demonstrate #7732 GitLab pipeline job details never load in Checks panel Co-authored-by: Orca * fix(gitlab): load pipeline job traces in the Checks side panel (#7732) Expanding a GitLab pipeline job in the Checks panel always showed "No inline details are available for this check.": the mapper dropped the numeric job id, `PRCheckDetail` had nowhere to carry it, and every consumer called the GitHub check-runs API, which returns null for a GitLab job. - carry `gitlabJobId` on `PRCheckDetail` and add the `gitlab-job:` branch to all three identity ladders (panel rows, editor tabs, fix-prompt keys) so same-stage jobs with no web_url stop colliding - add a runtime-routed trace client so SSH/remote workspaces work, not just local IPC, and thread the MR's `projectRef` for fork pipelines - bound the trace in main via the existing `sliceCheckLogTail` (now shared, not GitHub-only) so a multi-megabyte CI log never crosses the 1 MB transport frame cap; strip ANSI/section markers up to the CR only, which keeps each section's visible header and command echo - render the excerpt inline instead of "Log tail available in full details." - feed GitLab traces to "Fix with AI", which previously sent bare check names - skip the fetch for jobs that cannot have a trace (created/manual/skipped) so GitLab's 404 does not replace the benign empty state, and re-arm a failed load when the job's state changes since the panel has no retry Co-authored-by: Orca * fix(gitlab): treat a missing job log as an empty log, not an error (#7732) Round-1 review follow-up. - a job canceled before it started (or whose log was erased/expired) is `completed`/`cancelled`, so the panel fetched its trace, GitLab answered 404, and `classifyGlabError`'s issue-edit copy ("Issue not found — it may have been deleted.") landed verbatim on the auto-expanded check row; main now maps that 404 to an empty trace so the row keeps its benign empty state - keep a missing project a real error (GitLab masks unauthorized projects as 404) and add `classifyJobLogError` so 403/unknown failures stop borrowing issue-edit wording on a job-log read - broaden the empty-log copy in all five catalogs: it now covers erased and expired logs, not only jobs that never ran - e2e: derive the repro screenshot dir from `process.cwd()` (or an env override) instead of a hardcoded POSIX path to a throwaway worktree - bound the raw trace before the ANSI/section passes so a multi-megabyte log is not scanned in full on the main-process event loop - drop the redundant `if (repo)` in `handleFixChecksWithAI` and the now-dead "Log tail available in full details." catalog entry Co-authored-by: Orca * fix(gitlab): address review — project ref on reload, retry re-arm, IPC timeout - Carry the MR's GitLab project ref on the check-details tab so reloading a fork/cross-project job tab fetches the trace from the pipeline's own project. - Re-arm the sidebar retry when a details load resolves to null, not only when it throws; a detail-less row otherwise never retried after the job moved on. - Bound the local `gl.jobTrace` IPC call with the same 30s timeout the runtime RPC path uses — glab runs without a subprocess timeout in main. - Document that the trace 404 -> empty-log mapping is deliberately broad. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/github/client.ts | 2 +- src/main/gitlab/client-mr.test.ts | 26 +++ src/main/gitlab/client.ts | 9 +- src/main/gitlab/gl-utils.test.ts | 14 ++ src/main/gitlab/gl-utils.ts | 7 +- src/main/gitlab/glab-error-classification.ts | 35 ++++ src/main/ipc/gitlab.test.ts | 28 +++ src/main/ipc/gitlab.ts | 10 +- src/main/runtime/rpc/methods/gitlab.test.ts | 28 +++ src/main/runtime/rpc/methods/gitlab.ts | 20 +- src/preload/api-types.ts | 2 + src/preload/gitlab.ts | 2 +- .../automation-run-output-snapshot.ts | 22 +-- .../editor/check-run-details-tab.test.ts | 12 ++ .../editor/check-run-details-tab.ts | 20 ++ .../components/pr-checks-fix-prompt.test.ts | 21 ++ .../components/right-sidebar/ChecksPanel.tsx | 92 ++++++--- .../checks-list-expanded-details.test.tsx | 148 +++++++++++++- .../right-sidebar/checks-panel-content.tsx | 81 ++++++-- src/renderer/src/i18n/locales/en.json | 9 +- src/renderer/src/i18n/locales/es.json | 9 +- src/renderer/src/i18n/locales/ja.json | 9 +- src/renderer/src/i18n/locales/ko.json | 9 +- src/renderer/src/i18n/locales/zh.json | 9 +- .../runtime/gitlab-job-trace-client.test.ts | 184 ++++++++++++++++++ .../src/runtime/gitlab-job-trace-client.ts | 105 ++++++++++ src/renderer/src/store/slices/editor.test.ts | 149 ++++++++++++++ src/renderer/src/store/slices/editor.ts | 71 ++++--- src/shared/ansi-escape-sequences.ts | 27 +++ .../check-job-log-tail-slice.test.ts | 0 .../check-job-log-tail-slice.ts | 20 +- src/shared/gitlab-job-log-excerpt.test.ts | 90 +++++++++ src/shared/gitlab-job-log-excerpt.ts | 52 +++++ .../gitlab-job-trace-check-details.test.ts | 97 +++++++++ src/shared/gitlab-job-trace-check-details.ts | 70 +++++++ src/shared/gitlab-pipeline-checks.test.ts | 24 ++- src/shared/gitlab-pipeline-checks.ts | 4 +- src/shared/pr-checks-fix-prompt.ts | 5 + .../repro-7732-gitlab-job-id-dropped.test.ts | 29 +++ src/shared/types.ts | 3 + ...pro-7732-gitlab-checks-job-details.spec.ts | 160 +++++++++++++++ 41 files changed, 1595 insertions(+), 119 deletions(-) create mode 100644 src/renderer/src/runtime/gitlab-job-trace-client.test.ts create mode 100644 src/renderer/src/runtime/gitlab-job-trace-client.ts create mode 100644 src/shared/ansi-escape-sequences.ts rename src/{main/github => shared}/check-job-log-tail-slice.test.ts (100%) rename src/{main/github => shared}/check-job-log-tail-slice.ts (82%) create mode 100644 src/shared/gitlab-job-log-excerpt.test.ts create mode 100644 src/shared/gitlab-job-log-excerpt.ts create mode 100644 src/shared/gitlab-job-trace-check-details.test.ts create mode 100644 src/shared/gitlab-job-trace-check-details.ts create mode 100644 src/shared/repro-7732-gitlab-job-id-dropped.test.ts create mode 100644 tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 24772ebee4b..07472e5c096 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -38,7 +38,7 @@ import { import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { sliceCheckLogTail } from './check-job-log-tail-slice' +import { sliceCheckLogTail } from '../../shared/check-job-log-tail-slice' import { classifyPRRefreshError, safePRRefreshErrorMessage diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index de8d36494d8..c3c4de25a3f 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -1090,6 +1090,32 @@ describe('gitlab client — MR operations', () => { ) }) + // #7732: a job canceled before it started 404s; the Checks panel must show its + // benign empty state, not the issue-edit copy classifyGlabError would produce. + it('reports a 404 trace as an empty log rather than an error', async () => { + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404 Not Found')) + + await expect(getJobTrace('/repo', 99, 'upstream', 'conn-1')).resolves.toEqual({ + ok: true, + trace: '' + }) + }) + + it('keeps a missing project and a scope failure as errors, in job-log wording', async () => { + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Project Not Found')) + await expect(getJobTrace('/repo', 99, 'upstream', 'conn-1')).resolves.toEqual({ + ok: false, + error: "Could not find this job's GitLab project." + }) + + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 insufficient_scope')) + const forbidden = await getJobTrace('/repo', 99, 'upstream', 'conn-1') + expect(forbidden).toEqual({ + ok: false, + error: "You don't have permission to read this job's log. Check your GitLab token scopes." + }) + }) + it('retries a job through the selected SSH GitLab host', async () => { glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 2ff7f618488..74815729ebc 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -25,7 +25,9 @@ import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } import { acquire, classifyGlabError, + classifyJobLogError, classifyListIssuesError, + isMissingJobLogError, getGlabKnownHosts, getProjectRef, getProjectRefForRemote, @@ -1236,7 +1238,12 @@ export async function getJobTrace( return { ok: true, trace: stdout } } catch (err) { const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } + // A job with no log is an empty log, not a failure: surfacing the 404 would + // pin an error on the Checks row for a job canceled before it started. + if (isMissingJobLogError(msg)) { + return { ok: true, trace: '' } + } + return { ok: false, error: classifyJobLogError(msg).message } } finally { release() } diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index e1937f3f2aa..c2396c9aa7d 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -16,8 +16,10 @@ import { _resetKnownHostsCache, _resetProjectRefCache, classifyGlabError, + classifyJobLogError, classifyListIssuesError, getIssueProjectRef, + isMissingJobLogError, getGlabKnownHosts, getProjectRef, getProjectRefForRemote, @@ -421,6 +423,18 @@ describe('glab error classification', () => { expect(classifyListIssuesError('HTTP 403').message).toMatch(/permission to read issues/i) expect(classifyListIssuesError('HTTP 404').message).toBe('Project not found.') }) + + it('rewrites issue-edit copy for job logs via classifyJobLogError', () => { + expect(classifyJobLogError('HTTP 403').message).toMatch(/permission to read this job's log/i) + expect(classifyJobLogError('HTTP 403').message).not.toMatch(/issue/i) + expect(classifyJobLogError('boom').message).toBe('Failed to load the job log: boom') + }) + + it('treats a 404 job log as missing, but keeps a missing project an error', () => { + expect(isMissingJobLogError('HTTP 404 Not Found')).toBe(true) + expect(isMissingJobLogError('HTTP 404: Project Not Found')).toBe(false) + expect(isMissingJobLogError('HTTP 403 Forbidden')).toBe(false) + }) }) describe('glab auth status host parsing', () => { diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index a8a7b5705a0..6531793dac6 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -2,7 +2,12 @@ import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' import { parseGlabApiResponse, type GlabApiResponse } from './glab-api-response' export { glabExecFileAsync, gitExecFileAsync } -export { classifyGlabError, classifyListIssuesError } from './glab-error-classification' +export { + classifyGlabError, + classifyJobLogError, + classifyListIssuesError, + isMissingJobLogError +} from './glab-error-classification' export { DEFAULT_GITLAB_HOSTS, _getProjectRefCacheSize, diff --git a/src/main/gitlab/glab-error-classification.ts b/src/main/gitlab/glab-error-classification.ts index e1f740749e0..d769bdd108c 100644 --- a/src/main/gitlab/glab-error-classification.ts +++ b/src/main/gitlab/glab-error-classification.ts @@ -50,3 +50,38 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { } return { type: c.type, message: readMessages[c.type] } } + +// Why: a job trace is a read on a pipeline job, so classifyGlabError's issue-edit +// copy ("permission to edit this issue") would land verbatim on a Checks row. +export function classifyJobLogError(stderr: string): ClassifiedError { + const c = classifyGlabError(stderr) + const trimmed = stderr.trim() + const logMessages: Record = { + permission_denied: + "You don't have permission to read this job's log. Check your GitLab token scopes.", + // A missing log is already reported as an empty log, so a 404 that reaches here + // means the project itself is gone or invisible to this token. + not_found: "Could not find this job's GitLab project.", + issues_disabled: `Failed to load the job log: ${trimmed}`, + validation_error: `Invalid request — ${trimmed}`, + rate_limited: 'GitLab rate limit hit. Try again in a few minutes.', + network_error: 'Network error — check your connection.', + unknown: `Failed to load the job log: ${trimmed}` + } + return { type: c.type, message: logMessages[c.type] } +} + +/** + * Whether a failed trace fetch means "this job has no log", not "the fetch broke". + * + * GitLab 404s the trace endpoint for a job canceled before it started and for a log + * that was erased or expired. A missing *project* is a real failure, and GitLab also + * masks unauthorized projects as 404, so keep that one an error. + * + * Deliberately broad: GitLab returns the same bare 404 for an unknown job id, so a + * stale/foreign id reads as an empty log rather than an error. Losing that diagnostic + * beats pinning "not found" on every job that was canceled before it produced a log. + */ +export function isMissingJobLogError(stderr: string): boolean { + return classifyGlabError(stderr).type === 'not_found' && !/project\s+not\s+found/i.test(stderr) +} diff --git a/src/main/ipc/gitlab.test.ts b/src/main/ipc/gitlab.test.ts index 7002f31d853..2ff888a7bd8 100644 --- a/src/main/ipc/gitlab.test.ts +++ b/src/main/ipc/gitlab.test.ts @@ -675,4 +675,32 @@ describe('GitLab IPC handlers', () => { localGitOptions ) }) + + // Regression for #7732: raw CI traces routinely exceed the 1 MB runtime transport + // frame cap, so the Checks panel opts into a main-side excerpt. + it('bounds the job trace in main when the caller asks for a log excerpt', async () => { + const noisyTrace = [ + 'section_start:1699000000:build\r\u001b[0K$ pnpm build', + ...Array.from({ length: 400 }, (_, index) => `line ${index}`), + '\u001b[0;31mERROR: Job failed: exit code 1\u001b[0m' + ].join('\n') + getJobTraceMock.mockResolvedValue({ ok: true, trace: noisyTrace }) + registerGitLabHandlers(storeWithRepos([repo()]) as Store) + + const raw = (await ipcHandlers.get('gitlab:jobTrace')?.(null, { + repoPath: '/local/orca', + jobId: 99 + })) as { ok: true; trace: string } + const excerpt = (await ipcHandlers.get('gitlab:jobTrace')?.(null, { + repoPath: '/local/orca', + jobId: 99, + logExcerpt: true + })) as { ok: true; trace: string } + + expect(raw.trace).toBe(noisyTrace) + expect(excerpt.trace).toContain('ERROR: Job failed: exit code 1') + expect(excerpt.trace).not.toContain('section_start') + expect(excerpt.trace).not.toContain('line 0\n') + expect(excerpt.trace.length).toBeLessThan(noisyTrace.length) + }) }) diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index f400ce7a5ce..e68c4b8f6d8 100644 --- a/src/main/ipc/gitlab.ts +++ b/src/main/ipc/gitlab.ts @@ -3,6 +3,7 @@ GitLab IPC handlers co-located keeps the repo-path validation pattern reviewable as one surface. */ import { ipcMain } from 'electron' import { resolve } from 'node:path' +import { toGitLabJobLogExcerptResult } from '../../shared/gitlab-job-log-excerpt' import type { GitLabIssueUpdate, GitLabMRInlineCommentInput, @@ -520,10 +521,14 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:jobTrace', async ( _event, - args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: ProjectRef | null } + args: GitLabRepoSelectorArgs & { + jobId: number + projectRef?: ProjectRef | null + logExcerpt?: boolean + } ) => { const repo = assertRegisteredRepo(args, store) - return getJobTrace( + const result = await getJobTrace( repo.path, args.jobId, repo.issueSourcePreference, @@ -531,6 +536,7 @@ export function registerGitLabHandlers(store: Store): void { args.projectRef, ...localGitOptionArgs(store, repo) ) + return args.logExcerpt ? toGitLabJobLogExcerptResult(result) : result } ) diff --git a/src/main/runtime/rpc/methods/gitlab.test.ts b/src/main/runtime/rpc/methods/gitlab.test.ts index ca429f3d492..fe1ecd113c7 100644 --- a/src/main/runtime/rpc/methods/gitlab.test.ts +++ b/src/main/runtime/rpc/methods/gitlab.test.ts @@ -313,4 +313,32 @@ describe('gitlab RPC methods', () => { ) expect(runtime.listGitLabRepoIssues).toHaveBeenNthCalledWith(2, 'id:repo-1', 'opened', '@me', 1) }) + + // Regression for #7732: the WS/relay transports close the connection on frames + // over 1 MB, so the excerpt must be produced before the response is serialised. + it('bounds the job trace before it crosses the transport when logExcerpt is set', async () => { + const noisyTrace = [ + 'section_start:1699000000:build\r\u001b[0K$ pnpm build', + ...Array.from({ length: 400 }, (_, index) => `line ${index}`), + 'ERROR: Job failed: exit code 1' + ].join('\n') + const runtime = { + getRuntimeId: () => 'test-runtime', + getGitLabRepoJobTrace: vi.fn().mockResolvedValue({ ok: true, trace: noisyTrace }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITLAB_METHODS }) + + const raw = await dispatcher.dispatch( + makeRequest('gitlab.jobTrace', { repo: 'id:repo-1', jobId: 99 }) + ) + const excerpt = await dispatcher.dispatch( + makeRequest('gitlab.jobTrace', { repo: 'id:repo-1', jobId: 99, logExcerpt: true }) + ) + + expect((raw as { result: { trace: string } }).result.trace).toBe(noisyTrace) + const bounded = (excerpt as { result: { trace: string } }).result.trace + expect(bounded).toContain('ERROR: Job failed: exit code 1') + expect(bounded).not.toContain('section_start') + expect(bounded).not.toContain('line 0\n') + }) }) diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts index c10a7eee7b5..81c298d57bb 100644 --- a/src/main/runtime/rpc/methods/gitlab.ts +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import { normalizeGitLabIssueListArgs } from '../../../gitlab/gitlab-preload-args' +import { toGitLabJobLogExcerptResult } from '../../../../shared/gitlab-job-log-excerpt' const RepoSelector = z.object({ repo: requiredString('Missing repo selector') @@ -16,12 +17,14 @@ const GitLabRateLimit = z .optional() .default({}) +// nullish, not optional: renderer callers normalise a missing ref to `null` +// (`item.projectRef ?? null`), which a bare `.optional()` would reject outright. const GitLabProjectRef = z .object({ host: requiredString('Missing GitLab host'), path: requiredString('Missing GitLab project path') }) - .optional() + .nullish() const WorkItemsList = RepoSelector.extend({ state: z.enum(['opened', 'merged', 'closed', 'all']).optional(), @@ -121,7 +124,10 @@ const ResolveMRDiscussion = RepoSelector.extend({ const JobTrace = RepoSelector.extend({ jobId: z.number().int().positive(), - projectRef: GitLabProjectRef + projectRef: GitLabProjectRef, + // Why: raw CI traces routinely exceed the 1 MB transport frame cap, so callers + // that only render an excerpt ask main to bound it before it crosses the wire. + logExcerpt: z.boolean().optional() }) const RetryJob = RepoSelector.extend({ @@ -245,8 +251,14 @@ export const GITLAB_METHODS: RpcMethod[] = [ defineMethod({ name: 'gitlab.jobTrace', params: JobTrace, - handler: async (params, { runtime }) => - runtime.getGitLabRepoJobTrace(params.repo, params.jobId, params.projectRef) + handler: async (params, { runtime }) => { + const result = await runtime.getGitLabRepoJobTrace( + params.repo, + params.jobId, + params.projectRef + ) + return params.logExcerpt ? toGitLabJobLogExcerptResult(result) : result + } }), defineMethod({ name: 'gitlab.retryJob', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7f60af7e8e3..859be396e2e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2129,6 +2129,8 @@ export type PreloadApi = { args: GitLabRepoSelectorArgs & { jobId: number projectRef?: GitLabProjectRef | null + /** Bound the trace in main to a readable excerpt (see gitLabJobTraceToLogExcerpt). */ + logExcerpt?: boolean } ) => Promise retryJob: ( diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index 28c6f763e0f..8fbe1105af8 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -154,7 +154,7 @@ export const glApi = { ): Promise => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), jobTrace: ( - args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown } + args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown; logExcerpt?: boolean } ): Promise => ipcRenderer.invoke('gitlab:jobTrace', args), retryJob: ( diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot.ts b/src/renderer/src/components/automations/automation-run-output-snapshot.ts index a7b9151fbfc..91c9e88c592 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot.ts @@ -1,17 +1,11 @@ -/* eslint-disable no-control-regex -- terminal snapshots normalize ANSI/control output. */ import type { AutomationRunOutputSnapshot } from '../../../../shared/automations-types' +import { + stripAnsiEscapeSequences, + TERMINAL_CONTROL_CHARACTER_PATTERN +} from '../../../../shared/ansi-escape-sequences' const MAX_OUTPUT_SNAPSHOT_CHARS = 256 * 1024 -// Why: Codex/Claude TUIs emit OSC title/progress frames in hidden automation -// PTYs; saved run output should keep command text, not terminal metadata. -const OSC_SEQUENCE_PATTERN = /(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c)/g -const STRING_SEQUENCE_PATTERN = - /(?:\u001b[P_^X]|\u0090|\u0098|\u009e|\u009f)[\s\S]*?(?:\u001b\\|\u009c)/g -const CSI_SEQUENCE_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g -const ESCAPE_SEQUENCE_PATTERN = /\u001b[ -/]*[0-~]/g -const CONTROL_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g - export type AutomationRunOutputSnapshotBuffer = { append: (chunk: string) => void snapshot: () => AutomationRunOutputSnapshot | null @@ -43,14 +37,10 @@ export function selectAutomationRunOutputSnapshot( } function stripTerminalControls(value: string): string { - return value - .replace(OSC_SEQUENCE_PATTERN, '') - .replace(STRING_SEQUENCE_PATTERN, '') - .replace(CSI_SEQUENCE_PATTERN, '') - .replace(ESCAPE_SEQUENCE_PATTERN, '') + return stripAnsiEscapeSequences(value) .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') - .replace(CONTROL_PATTERN, '') + .replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '') } export function createAutomationRunOutputSnapshotBuffer(): AutomationRunOutputSnapshotBuffer { diff --git a/src/renderer/src/components/editor/check-run-details-tab.test.ts b/src/renderer/src/components/editor/check-run-details-tab.test.ts index c6beb882802..5646f523263 100644 --- a/src/renderer/src/components/editor/check-run-details-tab.test.ts +++ b/src/renderer/src/components/editor/check-run-details-tab.test.ts @@ -38,6 +38,18 @@ describe('check-run-details-tab', () => { ).toBe('url:https://github.com/acme/widgets/actions/runs/1') }) + it('keys GitLab jobs by job id so same-named stages do not share one tab', () => { + const base = { status: 'completed', conclusion: 'failure', url: null } as const + + expect(getCheckRunTabIdentity({ ...base, name: 'test: unit', gitlabJobId: 42 })).toBe( + 'gitlab-job:42' + ) + // Why: GitLab reuses `stage: name` across pipeline runs, so the name alone collides. + expect( + buildCheckRunDetailsTabId('wt-1', { ...base, name: 'test: unit', gitlabJobId: 42 }) + ).not.toBe(buildCheckRunDetailsTabId('wt-1', { ...base, name: 'test: unit', gitlabJobId: 43 })) + }) + it('uses the check name for the tab label', () => { expect( getCheckRunDetailsTabLabel({ diff --git a/src/renderer/src/components/editor/check-run-details-tab.ts b/src/renderer/src/components/editor/check-run-details-tab.ts index 0acd550f7b2..bbc8fc1180b 100644 --- a/src/renderer/src/components/editor/check-run-details-tab.ts +++ b/src/renderer/src/components/editor/check-run-details-tab.ts @@ -1,3 +1,4 @@ +import type { GitLabProjectRef } from '../../../../shared/gitlab-types' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' export type OpenCheckRunDetailsState = { @@ -6,6 +7,20 @@ export type OpenCheckRunDetailsState = { details: PRCheckRunDetails | null loading: boolean error: string | null + /** Why: fork/cross-project MR jobs live outside the repo's own project, so reloads need the pipeline's project. */ + gitlabProjectRef?: GitLabProjectRef | null +} + +export type CheckRunDetailsTabPatch = Pick< + OpenCheckRunDetailsState, + 'details' | 'loading' | 'error' | 'gitlabProjectRef' +> + +export function isSameGitLabProjectRef( + a: GitLabProjectRef | null, + b: GitLabProjectRef | null +): boolean { + return a === b || (a?.host === b?.host && a?.path === b?.path) } export function getCheckRunTabIdentity(check: PRCheckDetail): string { @@ -15,6 +30,11 @@ export function getCheckRunTabIdentity(check: PRCheckDetail): string { if (check.workflowRunId) { return `workflow-run:${check.workflowRunId}` } + // Why: GitLab jobs reuse `stage: name` across pipeline runs and can lack a web_url, + // so the numeric job id is the only stable per-job tab identity. + if (check.gitlabJobId) { + return `gitlab-job:${check.gitlabJobId}` + } if (check.url) { return `url:${check.url}` } diff --git a/src/renderer/src/components/pr-checks-fix-prompt.test.ts b/src/renderer/src/components/pr-checks-fix-prompt.test.ts index 2884138c0ef..b3f2eadf600 100644 --- a/src/renderer/src/components/pr-checks-fix-prompt.test.ts +++ b/src/renderer/src/components/pr-checks-fix-prompt.test.ts @@ -34,6 +34,27 @@ afterEach(() => { vi.restoreAllMocks() }) +describe('getCheckDetailsPromptKey', () => { + // Regression for #7732: a GitLab job with no web_url would otherwise key on its + // list index, so its freshly loaded log would never reach the fix prompt. + it('keys GitLab jobs by job id ahead of the index fallback', () => { + const manualJob: PRCheckDetail = { + name: 'deploy: production', + status: 'completed', + conclusion: 'failure', + url: null, + gitlabJobId: 987654 + } + + expect(getCheckDetailsPromptKey(manualJob, 0)).toBe('gitlab-job:987654:deploy: production') + expect(getCheckDetailsPromptKey(manualJob, 3)).toBe(getCheckDetailsPromptKey(manualJob, 0)) + }) + + it('leaves GitHub check identities untouched', () => { + expect(getCheckDetailsPromptKey(failingCheck, 0)).toBe('check-run:11') + }) +}) + describe('buildFixBrokenChecksPrompt', () => { it('keeps names-only broken check data when no details are provided', () => { const prompt = buildPrompt({}) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 0dc26aa38c2..2a1412cde94 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -58,6 +58,7 @@ import { import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { GitLabDiscussionResolveResult, + GitLabProjectRef, GitLabWorkItemDetails, PRInfo, PRCheckDetail, @@ -159,6 +160,7 @@ import { import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { useMountedRef } from '@/hooks/useMountedRef' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { loadGitLabJobLogDetails } from '@/runtime/gitlab-job-trace-client' import { gitLabPipelineJobsToPRChecks } from '../../../../shared/gitlab-pipeline-checks' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog' @@ -370,6 +372,10 @@ function isGitLabChecksPanelReview( return review?.provider === 'gitlab' } +function hasGitHubCheckHandle(check: PRCheckDetail): boolean { + return Boolean(check.checkRunId || check.workflowRunId || check.url) +} + function gitLabMRCommentsToPRComments( comments: GitLabWorkItemDetails['comments'] | undefined ): PRComment[] { @@ -564,6 +570,9 @@ export default function ChecksPanel(): React.JSX.Element { const mountedRef = useMountedRef() const confirm = useConfirmationDialog() const prevChecksRef = useRef('') + // Why: a fork MR's pipeline lives in the source project, so job traces must be + // fetched against the MR's own project rather than this repo's default remote. + const gitLabProjectRefRef = useRef(null) const conflictSummaryRefreshKeyRef = useRef(null) const panelVisibleSinceRef = useRef(null) const foregroundedUnrenderedReviewKeyRef = useRef(null) @@ -1925,6 +1934,7 @@ export default function ChecksPanel(): React.JSX.Element { if (!isCurrentAsyncResult(requestKey)) { return } + gitLabProjectRefRef.current = details?.item.projectRef ?? null const result = gitLabPipelineJobsToPRChecks(details?.pipelineJobs ?? []) setChecks(result) setComments(gitLabMRCommentsToPRComments(details?.comments)) @@ -2063,6 +2073,17 @@ export default function ChecksPanel(): React.JSX.Element { if (!repo) { return Promise.resolve(null) } + if (check.gitlabJobId) { + // Why: `settings` (not ownerSettings) is what fetched the job list, so the + // job id and its trace always resolve against the same host. + return loadGitLabJobLogDetails({ + repoPath: repo.path, + repoId: repo.id, + settings, + check, + projectRef: gitLabProjectRefRef.current + }) + } return fetchPRCheckDetails( repo.path, { @@ -2075,9 +2096,12 @@ export default function ChecksPanel(): React.JSX.Element { { repoId: repo.id } ) }, - [fetchPRCheckDetails, pr?.prRepo, repo] + [fetchPRCheckDetails, pr?.prRepo, repo, settings] ) + // Why: read at call time — the ref is filled by an async MR fetch, so a value prop would be stale. + const getGitLabProjectRef = useCallback(() => gitLabProjectRefRef.current, []) + useEffect(() => { if (activeGitLabReview) { return @@ -3417,33 +3441,45 @@ export default function ChecksPanel(): React.JSX.Element { setIsFixingChecksWithAI(true) try { const checkRunDetailsByCheckKey: Record = {} - if (activeReview.provider !== 'gitlab' && repo) { - await Promise.all( - broken.slice(0, 5).map(async (check, index) => { - if (!check.checkRunId && !check.workflowRunId && !check.url) { - return + await Promise.all( + broken.slice(0, 5).map(async (check, index) => { + const isGitLabJob = Boolean(check.gitlabJobId) + if ( + !isGitLabJob && + (activeReview.provider === 'gitlab' || !hasGitHubCheckHandle(check)) + ) { + return + } + try { + // Why: GitLab job logs are now loadable, so the fix prompt gets the same + // failure context the sidebar shows instead of check names alone. + const details = isGitLabJob + ? await loadGitLabJobLogDetails({ + repoPath: repo.path, + repoId: repo.id, + settings, + check, + projectRef: gitLabProjectRefRef.current + }) + : await fetchPRCheckDetails( + repo.path, + { + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo: pr?.prRepo ?? null + }, + { repoId: repo.id } + ) + if (details) { + checkRunDetailsByCheckKey[getCheckDetailsPromptKey(check, index)] = details } - try { - const details = await fetchPRCheckDetails( - repo.path, - { - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo: pr?.prRepo ?? null - }, - { repoId: repo.id } - ) - if (details) { - checkRunDetailsByCheckKey[getCheckDetailsPromptKey(check, index)] = details - } - } catch (error) { - console.warn('[ChecksPanel] failed to load check details for AI fix prompt', error) - } - }) - ) - } + } catch (error) { + console.warn('[ChecksPanel] failed to load check details for AI fix prompt', error) + } + }) + ) if (!isCurrentAsyncResult(requestKey)) { return } @@ -3482,6 +3518,7 @@ export default function ChecksPanel(): React.JSX.Element { isFixingChecksWithAI, pr?.prRepo, repo, + settings, sourceControlAiActionsVisible, stateRequestKey ]) @@ -4445,6 +4482,7 @@ export default function ChecksPanel(): React.JSX.Element { checksLoading={checksLoading} checkDetailsContextKey={stateRequestKey} onLoadCheckDetails={handleLoadCheckDetails} + getGitLabProjectRef={getGitLabProjectRef} /> )} Promise }> = {} ): void { @@ -91,7 +92,7 @@ function renderChecksList( root.render( { expect(container.textContent).toContain('manual action on GitHub') expect(container.textContent).not.toContain('No inline details are available') }) + + it('renders the log excerpt inline instead of deferring it to the full-details tab', async () => { + renderChecksList({ worktreeId: 'wt-child-1' }) + + await act(async () => { + await Promise.resolve() + }) + + expect(container.textContent).toContain('Error: assertion failed') + expect(container.textContent).not.toContain('Log tail available in full details') + }) + + // Regression for #7732: manual/created GitLab jobs carry no web_url and no GitHub + // handles, so the panel used to short-circuit before ever asking for their log. + it('loads details for a GitLab job that only carries a job id', async () => { + const gitLabCheck: PRCheckDetail = { + name: 'deploy: production', + status: 'completed', + conclusion: 'failure', + url: null, + gitlabJobId: 987654 + } + const onLoadCheckDetails = vi.fn(async () => ({ + ...checkDetails, + name: gitLabCheck.name, + jobs: [{ ...checkDetails.jobs[0]!, id: 987654, logTail: 'ERROR: Job failed: exit code 1' }] + })) + + renderChecksList({ worktreeId: 'wt-child-1', checks: [gitLabCheck], onLoadCheckDetails }) + + await act(async () => { + await Promise.resolve() + }) + + expect(onLoadCheckDetails).toHaveBeenCalledWith(gitLabCheck) + expect(container.textContent).toContain('ERROR: Job failed: exit code 1') + expect(container.textContent).not.toContain('No inline details are available') + expect(container.querySelector('.sticky.top-0')?.textContent).toContain('View full logs') + }) + + // GitLab trace fetches fail for reasons GitHub's never do (auth, 404, self-hosted + // host resolution) and the panel has no retry button, so a transient failure must + // not pin its error on the row for the rest of the session. + it('retries a failed detail load once the check state moves on', async () => { + const runningCheck: PRCheckDetail = { + name: 'test: unit', + status: 'in_progress', + conclusion: 'failure', + url: null, + gitlabJobId: 5150 + } + const onLoadCheckDetails = vi + .fn<(check: PRCheckDetail) => Promise>() + .mockRejectedValueOnce(new Error('401 Unauthorized')) + .mockResolvedValueOnce({ + ...checkDetails, + name: runningCheck.name, + status: 'completed', + conclusion: 'failure', + jobs: [{ ...checkDetails.jobs[0]!, id: 5150, logTail: 'ERROR: Job failed: exit code 1' }] + }) + + renderChecksList({ worktreeId: 'wt-child-1', checks: [runningCheck], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + + expect(container.textContent).toContain('401 Unauthorized') + expect(onLoadCheckDetails).toHaveBeenCalledTimes(1) + + const finishedCheck: PRCheckDetail = { ...runningCheck, status: 'completed' } + renderChecksList({ worktreeId: 'wt-child-1', checks: [finishedCheck], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + + expect(onLoadCheckDetails).toHaveBeenCalledTimes(2) + expect(container.textContent).not.toContain('401 Unauthorized') + expect(container.textContent).toContain('ERROR: Job failed: exit code 1') + }) + + // A running GitLab job has no trace yet, so the first load legitimately resolves to + // null — without re-arming, the row stays "no inline details" after the job finishes. + it('retries a detail load that resolved to null once the check state moves on', async () => { + const runningCheck: PRCheckDetail = { + name: 'test: unit', + status: 'in_progress', + conclusion: 'failure', + url: null, + gitlabJobId: 5150 + } + const onLoadCheckDetails = vi + .fn<(check: PRCheckDetail) => Promise>() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + ...checkDetails, + name: runningCheck.name, + jobs: [{ ...checkDetails.jobs[0]!, id: 5150, logTail: 'ERROR: Job failed: exit code 1' }] + }) + + renderChecksList({ worktreeId: 'wt-child-1', checks: [runningCheck], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + + expect(onLoadCheckDetails).toHaveBeenCalledTimes(1) + expect(container.textContent).toContain('No inline details are available') + + const finishedCheck: PRCheckDetail = { ...runningCheck, status: 'completed' } + renderChecksList({ worktreeId: 'wt-child-1', checks: [finishedCheck], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + + expect(onLoadCheckDetails).toHaveBeenCalledTimes(2) + expect(container.textContent).not.toContain('No inline details are available') + expect(container.textContent).toContain('ERROR: Job failed: exit code 1') + }) + + it('keeps a failed detail load pinned while the check state is unchanged', async () => { + const onLoadCheckDetails = vi + .fn<(check: PRCheckDetail) => Promise>() + .mockRejectedValue(new Error('401 Unauthorized')) + const gitLabCheck: PRCheckDetail = { + name: 'test: unit', + status: 'completed', + conclusion: 'failure', + url: null, + gitlabJobId: 5150 + } + + renderChecksList({ worktreeId: 'wt-child-1', checks: [gitLabCheck], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + // Re-render with an equal-but-new row array, as every 30s poll tick does. + renderChecksList({ worktreeId: 'wt-child-1', checks: [{ ...gitLabCheck }], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + + // Why: re-arming on row identity alone would refetch on every poll tick. + expect(onLoadCheckDetails).toHaveBeenCalledTimes(1) + expect(container.textContent).toContain('401 Unauthorized') + }) }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index b6353e9380a..2b51b03f7b2 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -55,6 +55,7 @@ import { } from '@/components/ui/context-menu' import { cn } from '@/lib/utils' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { CheckJobLogTail } from './check-job-log-tail' import { filterPRCommentsByAudience, getPRCommentAudienceCounts, @@ -79,6 +80,7 @@ import { getPRCommentGroupSurfaceClasses, type PRCommentPresentationClasses } from './pr-comment-presentation' +import type { GitLabProjectRef } from '../../../../shared/gitlab-types' import type { PRInfo, PRCheckDetail, @@ -541,6 +543,8 @@ type CheckDetailsLoadState = { loading: boolean details: PRCheckRunDetails | null error: string | null + /** Check state when the load failed or returned nothing, so a later state change can retry it. */ + errorAt?: { status: PRCheckDetail['status']; conclusion: PRCheckDetail['conclusion'] } } function getCheckIdentityKey(check: PRCheckDetail, index: number): string { @@ -550,6 +554,11 @@ function getCheckIdentityKey(check: PRCheckDetail, index: number): string { if (check.workflowRunId) { return `workflow-run:${check.workflowRunId}` } + // Why: manual/created GitLab jobs have no web_url, so they would otherwise key on + // the list index and lose their cached log whenever the pipeline re-sorts. + if (check.gitlabJobId) { + return `gitlab-job:${check.gitlabJobId}` + } if (check.url) { return `url:${check.url}` } @@ -661,13 +670,16 @@ function CheckRunDetails({ state, checkDetailsContextKey, worktreeId, - detailsStickySurface = 'sidebar' + detailsStickySurface = 'sidebar', + getGitLabProjectRef }: { check: PRCheckDetail state: CheckDetailsLoadState | undefined checkDetailsContextKey: string worktreeId: string | null detailsStickySurface?: CheckDetailsStickySurface + /** Why: a getter, not a value — the source ref is filled by an async fetch and would read stale during render. */ + getGitLabProjectRef?: () => GitLabProjectRef | null }): React.JSX.Element { const openCheckRunDetails = useAppStore((s) => s.openCheckRunDetails) const details = state?.details @@ -706,7 +718,8 @@ function CheckRunDetails({ openCheckRunDetails(worktreeId, checkDetailsContextKey, check, { details: state?.details ?? null, loading: state?.loading ?? false, - error: state?.error ?? null + error: state?.error ?? null, + gitlabProjectRef: getGitLabProjectRef?.() ?? null }) } @@ -914,6 +927,7 @@ function CheckRunDetails({ ))}
)} + {job.logTail && } ))} @@ -928,15 +942,6 @@ function CheckRunDetails({ )} - {hasLogTail && ( -
- {translate( - 'auto.components.right.sidebar.checks.panel.content.2524d1fb83', - 'Log tail available in full details.' - )} -
- )} - {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && (
{getCheckConclusion(detailsStatusCheck) === 'action_required' @@ -965,7 +970,8 @@ export function ChecksList({ checkDetailsContextKey, onLoadCheckDetails, worktreeId: worktreeIdOverride, - detailsStickySurface = 'sidebar' + detailsStickySurface = 'sidebar', + getGitLabProjectRef }: { checks: PRCheckDetail[] checksLoading: boolean @@ -974,6 +980,8 @@ export function ChecksList({ /** Why: folder-workspace PR checks render rows for attached worktrees, not the active one. */ worktreeId?: string detailsStickySurface?: CheckDetailsStickySurface + /** Why: a getter, not a value — the source ref is filled by an async fetch and would read stale during render. */ + getGitLabProjectRef?: () => GitLabProjectRef | null }): React.JSX.Element { const activeWorktree = useActiveWorktree() const resolvedWorktreeId = worktreeIdOverride ?? activeWorktree?.id ?? null @@ -1041,13 +1049,20 @@ export function ChecksList({ const next: Record = { ...current } for (const row of rows) { const cached = next[row.key] - if (!cached?.details) { + if (!cached || cached.loading) { continue } - if ( - cached.details.status !== row.check.status || - cached.details.conclusion !== row.check.conclusion - ) { + // Why: a failed load (GitLab auth blip, 404, offline) otherwise pins its error + // forever — there is no retry affordance — so re-arm it once the job moves on. + const stale = cached.details + ? cached.details.status !== row.check.status || + cached.details.conclusion !== row.check.conclusion + : Boolean( + cached.errorAt && + (cached.errorAt.status !== row.check.status || + cached.errorAt.conclusion !== row.check.conclusion) + ) + if (stale) { delete next[row.key] changed = true } @@ -1061,7 +1076,12 @@ export function ChecksList({ if (detailsByCheckKey[row.key]?.loading || detailsByCheckKey[row.key]?.details) { return } - if (!row.check.checkRunId && !row.check.workflowRunId && !row.check.url) { + if ( + !row.check.checkRunId && + !row.check.workflowRunId && + !row.check.url && + !row.check.gitlabJobId + ) { setDetailsByCheckKey((current) => ({ ...current, [row.key]: { @@ -1104,7 +1124,16 @@ export function ChecksList({ [row.key]: { loading: false, details, - error: details ? null : 'No inline details are available for this check.' + error: details + ? null + : translate( + 'auto.components.right.sidebar.checks.panel.content.e15a8b77ef', + 'No inline details are available for this check.' + ), + // Why: a detail-less result is only final for this status — re-arm the retry once the job moves on. + errorAt: details + ? undefined + : { status: row.check.status, conclusion: row.check.conclusion } } })) }) @@ -1117,7 +1146,14 @@ export function ChecksList({ [row.key]: { loading: false, details: null, - error: err instanceof Error ? err.message : 'Failed to load check details.' + error: + err instanceof Error + ? err.message + : translate( + 'auto.components.right.sidebar.checks.panel.content.checkDetailsLoadFailed', + 'Failed to load check details.' + ), + errorAt: { status: row.check.status, conclusion: row.check.conclusion } } })) }) @@ -1148,12 +1184,14 @@ export function ChecksList({ patchOpenCheckRunDetails(resolvedWorktreeId, checkDetailsContextKey, row.check, { details: detailsState.details ?? null, loading: detailsState.loading ?? false, - error: detailsState.error ?? null + error: detailsState.error ?? null, + gitlabProjectRef: getGitLabProjectRef?.() ?? null }) } }, [ checkDetailsContextKey, detailsByCheckKey, + getGitLabProjectRef, patchOpenCheckRunDetails, resolvedWorktreeId, rows @@ -1329,6 +1367,7 @@ export function ChecksList({ checkDetailsContextKey={checkDetailsContextKey} worktreeId={resolvedWorktreeId} detailsStickySurface={detailsStickySurface} + getGitLabProjectRef={getGitLabProjectRef} /> )}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b8580ef27f2..d0511b2ca4e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10804,7 +10804,6 @@ "a54ae21c6f": "Status:", "e4e3af15ee": "View full details", "b8c4e2a1f7": "View full logs", - "2524d1fb83": "Log tail available in full details.", "a2fb3f4408": "Showing first 100 jobs", "df137989b3": "Showing first 20 annotations", "1f2b980522": "Loading check details…", @@ -10856,7 +10855,8 @@ "5e6e5a13fa": "View", "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.", "b3195cba33": "Unmark author as bot", - "f588b46a6c": "Mark author as bot" + "f588b46a6c": "Mark author as bot", + "checkDetailsLoadFailed": "Failed to load check details." }, "empty": { "state": { @@ -14559,6 +14559,11 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "Failed to load the GitLab job log.", + "emptyTrace": "No log is available for this GitLab job.", + "timedOut": "Timed out loading the GitLab job log." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b9d562579e3..9797580ad75 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10636,7 +10636,6 @@ "fd46a70f1a": "Iniciado", "a54ae21c6f": "Estado:", "e4e3af15ee": "Ver detalles completos", - "2524d1fb83": "Cola del log disponible en los detalles completos.", "a2fb3f4408": "Mostrando los primeros 100 jobs", "df137989b3": "Mostrando las primeras 20 anotaciones", "1f2b980522": "Cargando detalles del check…", @@ -10689,7 +10688,8 @@ "5e6e5a13fa": "Vista", "actionRequiredHint": "Este check requiere una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de que el merge quede desbloqueado.", "b3195cba33": "Desmarcar autor como bot", - "f588b46a6c": "Marcar autor como bot" + "f588b46a6c": "Marcar autor como bot", + "checkDetailsLoadFailed": "No se pudieron cargar los detalles del check." }, "empty": { "state": { @@ -14348,6 +14348,11 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "No se pudo cargar el registro del job de GitLab.", + "emptyTrace": "No hay ningún registro disponible para este job de GitLab.", + "timedOut": "Se agotó el tiempo de espera al cargar el registro del job de GitLab." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f45c99439bd..eab737f2541 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10636,7 +10636,6 @@ "fd46a70f1a": "開始しました", "a54ae21c6f": "状態:", "e4e3af15ee": "詳細を表示", - "2524d1fb83": "ログテールで詳細を確認できます。", "a2fb3f4408": "最初の 100 件のジョブを表示しています", "df137989b3": "最初の 20 個の注釈を表示", "1f2b980522": "チェックの詳細を読み込んでいます…", @@ -10689,7 +10688,8 @@ "5e6e5a13fa": "表示", "actionRequiredHint": "マージが解除される前に、GitHubで手動アクションが必要です(例:ワークフロー実行の承認)。", "b3195cba33": "作成者のボット指定を解除", - "f588b46a6c": "作成者をボットとして指定" + "f588b46a6c": "作成者をボットとして指定", + "checkDetailsLoadFailed": "チェック詳細の読み込みに失敗しました。" }, "empty": { "state": { @@ -14348,6 +14348,11 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "GitLab ジョブのログを読み込めませんでした。", + "emptyTrace": "この GitLab ジョブのログはありません。", + "timedOut": "GitLab ジョブのログの読み込みがタイムアウトしました。" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1241301c0a8..6e49c537664 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10636,7 +10636,6 @@ "fd46a70f1a": "시작됨", "a54ae21c6f": "상태:", "e4e3af15ee": "전체 세부정보 보기", - "2524d1fb83": "자세한 내용을 로그 테일에서 확인할 수 있습니다.", "a2fb3f4408": "처음 100개 작업 표시", "df137989b3": "처음 20개의 주석 표시 중", "1f2b980522": "검사 세부 정보 로드 중…", @@ -10689,7 +10688,8 @@ "5e6e5a13fa": "보기", "actionRequiredHint": "이 검사는 병합 차단이 해제되기 전에 GitHub에서 수동 작업(예: 워크플로 실행 승인)이 필요합니다.", "b3195cba33": "작성자의 봇 지정 해제", - "f588b46a6c": "작성자를 봇으로 지정" + "f588b46a6c": "작성자를 봇으로 지정", + "checkDetailsLoadFailed": "체크 세부 정보를 불러오지 못했습니다." }, "empty": { "state": { @@ -14348,6 +14348,11 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "GitLab 작업 로그를 불러오지 못했습니다.", + "emptyTrace": "이 GitLab 작업의 로그를 사용할 수 없습니다.", + "timedOut": "GitLab 작업 로그를 불러오는 중 시간이 초과되었습니다." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 04117976acc..10f601cfed2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10648,7 +10648,6 @@ "fd46a70f1a": "开始", "a54ae21c6f": "状态:", "e4e3af15ee": "查看完整详细信息", - "2524d1fb83": "日志尾部提供完整详细信息。", "a2fb3f4408": "显示前 100 个作业", "df137989b3": "显示前 20 个注释", "1f2b980522": "正在加载检查详细信息...", @@ -10701,7 +10700,8 @@ "5e6e5a13fa": "查看", "actionRequiredHint": "此检查需要在 GitHub 上执行手动操作(例如批准工作流运行)后才能解除合并阻止。", "b3195cba33": "取消将作者标记为机器人", - "f588b46a6c": "将作者标记为机器人" + "f588b46a6c": "将作者标记为机器人", + "checkDetailsLoadFailed": "加载检查详细信息失败。" }, "empty": { "state": { @@ -14360,6 +14360,11 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "加载 GitLab 作业日志失败。", + "emptyTrace": "此 GitLab 作业没有可用的日志。", + "timedOut": "加载 GitLab 作业日志超时。" } } }, diff --git a/src/renderer/src/runtime/gitlab-job-trace-client.test.ts b/src/renderer/src/runtime/gitlab-job-trace-client.test.ts new file mode 100644 index 00000000000..10ecd88884c --- /dev/null +++ b/src/renderer/src/runtime/gitlab-job-trace-client.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PRCheckDetail } from '../../../shared/types' +import { loadGitLabJobLogDetails } from './gitlab-job-trace-client' + +const callRuntimeRpc = vi.hoisted(() => vi.fn()) +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc })) + +const jobTrace = vi.fn() + +const gitLabCheck: PRCheckDetail = { + name: 'Component Tests: Purchase API', + status: 'completed', + conclusion: 'failure', + url: null, + gitlabJobId: 42 +} + +beforeEach(() => { + callRuntimeRpc.mockReset() + jobTrace.mockReset() + ;(globalThis as { window?: unknown }).window = { api: { gl: { jobTrace } } } +}) + +describe('loadGitLabJobLogDetails', () => { + it('returns null for a check that is not a GitLab job, touching no transport', async () => { + const githubCheck: PRCheckDetail = { + name: 'verify', + status: 'completed', + conclusion: 'failure', + url: 'https://github.com/acme/orca/runs/1', + checkRunId: 7 + } + + await expect( + loadGitLabJobLogDetails({ repoPath: '/repo', settings: null, check: githubCheck }) + ).resolves.toBeNull() + expect(jobTrace).not.toHaveBeenCalled() + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('asks main for a bounded excerpt over local IPC', async () => { + jobTrace.mockResolvedValue({ ok: true, trace: 'ERROR: Job failed: exit code 1' }) + + const details = await loadGitLabJobLogDetails({ + repoPath: '/repo', + repoId: 'repo-1', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + + expect(jobTrace).toHaveBeenCalledWith({ + repoPath: '/repo', + repoId: 'repo-1', + jobId: 42, + projectRef: null, + logExcerpt: true + }) + expect(details?.jobs[0]?.logTail).toContain('ERROR: Job failed: exit code 1') + }) + + it('targets the MR project so a fork pipeline trace is not looked up in the wrong project', async () => { + jobTrace.mockResolvedValue({ ok: true, trace: 'boom' }) + + await loadGitLabJobLogDetails({ + repoPath: '/repo', + repoId: 'repo-1', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck, + projectRef: { host: 'gitlab.example.test', path: 'contributor/orca' } + }) + + expect(jobTrace).toHaveBeenCalledWith( + expect.objectContaining({ + projectRef: { host: 'gitlab.example.test', path: 'contributor/orca' } + }) + ) + }) + + it('does not request a trace for a manual job that cannot have one', async () => { + const manualCheck: PRCheckDetail = { + name: 'deploy: production', + status: 'completed', + conclusion: 'neutral', + url: null, + gitlabJobId: 77 + } + + const details = await loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: manualCheck + }) + + // Why: GitLab 404s on a job with no trace, so skip the round trip entirely. + expect(jobTrace).not.toHaveBeenCalled() + expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(details?.jobs).toEqual([]) + expect(details?.summary).toBe('No log is available for this GitLab job.') + }) + + // #7732 round 1: a job canceled before it started is `completed`/`cancelled`, so it is + // fetched; main answers with an empty trace and the row must show that, not an error. + it('explains an empty trace for a job canceled before it produced output', async () => { + jobTrace.mockResolvedValue({ ok: true, trace: '' }) + const canceledCheck: PRCheckDetail = { + name: 'unit', + status: 'completed', + conclusion: 'cancelled', + url: null, + gitlabJobId: 5 + } + + const details = await loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: canceledCheck + }) + + expect(details?.jobs).toEqual([]) + expect(details?.summary).toBe('No log is available for this GitLab job.') + }) + + it('routes to the owning runtime environment and still requests the excerpt', async () => { + callRuntimeRpc.mockResolvedValue({ ok: true, trace: 'remote failure' }) + + await loadGitLabJobLogDetails({ + repoPath: '/repo', + repoId: 'repo-1', + settings: { activeRuntimeEnvironmentId: 'env-9' }, + check: gitLabCheck + }) + + expect(jobTrace).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-9' }, + 'gitlab.jobTrace', + // Why: bounding in main keeps the response under the 1 MB transport frame cap. + { repo: 'repo-1', jobId: 42, projectRef: undefined, logExcerpt: true }, + { timeoutMs: 30_000 } + ) + }) + + it('surfaces the GitLab error verbatim so the panel does not claim "no details"', async () => { + jobTrace.mockResolvedValue({ ok: false, error: '403 Forbidden' }) + + await expect( + loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + ).rejects.toThrow('403 Forbidden') + }) + + it('gives up on a local IPC call that never settles, matching the remote timeout', async () => { + vi.useFakeTimers() + try { + jobTrace.mockReturnValue(new Promise(() => {})) + + const pending = loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + const assertion = expect(pending).rejects.toThrow('Timed out loading the GitLab job log.') + await vi.advanceTimersByTimeAsync(30_000) + await assertion + } finally { + vi.useRealTimers() + } + }) + + it('falls back to a translated message when GitLab reports a blank error', async () => { + jobTrace.mockResolvedValue({ ok: false, error: ' ' }) + + await expect( + loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + ).rejects.toThrow('Failed to load the GitLab job log.') + }) +}) diff --git a/src/renderer/src/runtime/gitlab-job-trace-client.ts b/src/renderer/src/runtime/gitlab-job-trace-client.ts new file mode 100644 index 00000000000..929c8581c1f --- /dev/null +++ b/src/renderer/src/runtime/gitlab-job-trace-client.ts @@ -0,0 +1,105 @@ +import { translate } from '@/i18n/i18n' +import { + gitLabJobCanHaveTrace, + gitLabJobTraceToCheckRunDetails +} from '../../../shared/gitlab-job-trace-check-details' +import type { GitLabJobTraceResult, GitLabProjectRef } from '../../../shared/gitlab-types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../shared/types' +import { getActiveRuntimeTarget } from './runtime-client-target' +import { callRuntimeRpc } from './runtime-rpc-client' + +const JOB_TRACE_TIMEOUT_MS = 30_000 + +/** + * Load a GitLab pipeline job log as provider-neutral check details. + * + * Returns null for checks that are not GitLab jobs so callers can fall through to + * their existing provider path. Throws on a GitLab-reported failure so the caller's + * error handling surfaces the message instead of showing "no details available". + */ +export async function loadGitLabJobLogDetails(args: { + repoPath: string + repoId?: string + settings: Parameters[0] + check: PRCheckDetail + /** Fork/cross-project MRs run their pipeline outside the repo's own project. */ + projectRef?: GitLabProjectRef | null +}): Promise { + const jobId = args.check.gitlabJobId + if (!jobId) { + return null + } + if (!gitLabJobCanHaveTrace(args.check)) { + return gitLabJobTraceToCheckRunDetails(args.check, '', emptyTraceStrings()) + } + const target = getActiveRuntimeTarget(args.settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'gitlab.jobTrace', + { + repo: args.repoId ?? args.repoPath, + jobId, + projectRef: args.projectRef ?? undefined, + logExcerpt: true + }, + { timeoutMs: JOB_TRACE_TIMEOUT_MS } + ) + : await withJobTraceTimeout( + window.api.gl.jobTrace({ + repoPath: args.repoPath, + repoId: args.repoId, + jobId, + projectRef: args.projectRef ?? null, + logExcerpt: true + }) + ) + if (!result?.ok) { + throw new Error( + result?.error?.trim() || + translate( + 'auto.runtime.gitlabJobTraceClient.loadFailed', + 'Failed to load the GitLab job log.' + ) + ) + } + return gitLabJobTraceToCheckRunDetails(args.check, result.trace, emptyTraceStrings()) +} + +/** + * Bound the local IPC call the way `callRuntimeRpc` bounds the remote one. + * + * `glab` runs without a subprocess timeout in main, so an unreachable GitLab host + * would otherwise leave the expanded Checks row spinning forever. + */ +function withJobTraceTimeout(pending: Promise): Promise { + let timer: ReturnType | undefined + return Promise.race([ + pending, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new Error( + translate( + 'auto.runtime.gitlabJobTraceClient.timedOut', + 'Timed out loading the GitLab job log.' + ) + ) + ) + }, JOB_TRACE_TIMEOUT_MS) + }) + ]).finally(() => { + clearTimeout(timer) + }) +} + +// Called per-request, not at module scope, so the active locale applies. +function emptyTraceStrings(): { emptyTrace: string } { + return { + emptyTrace: translate( + 'auto.runtime.gitlabJobTraceClient.emptyTrace', + 'No log is available for this GitLab job.' + ) + } +} diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index d8a554a5197..1f2c8474641 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -39,6 +39,10 @@ vi.mock('@/runtime/close-mirrored-editor-tab', () => ({ notifyHostOfMirroredEditorClose: (...args: unknown[]) => notifyHostOfMirroredEditorCloseMock(...args) })) +const loadGitLabJobLogDetailsMock = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/gitlab-job-trace-client', () => ({ + loadGitLabJobLogDetails: loadGitLabJobLogDetailsMock +})) function createEditorStore(): StoreApi { // Only the editor slice + activeWorktreeId are needed for these tests. @@ -2998,6 +3002,151 @@ describe('createEditorSlice conflict status reconciliation', () => { ) }) + // Regression for #7732: refreshing a GitLab job tab through the GitHub check-runs + // API returns null and blanks the tab the user just asked to reload. + it('reloads an open GitLab job tab through the job trace client', async () => { + loadGitLabJobLogDetailsMock.mockReset() + loadGitLabJobLogDetailsMock.mockResolvedValue({ + name: 'test: unit', + status: 'completed', + conclusion: 'failure', + url: null, + detailsUrl: null, + startedAt: null, + completedAt: null, + title: null, + summary: null, + text: null, + annotations: [], + jobs: [ + { + id: 42, + name: 'test: unit', + status: 'completed', + conclusion: 'failure', + startedAt: null, + completedAt: null, + url: null, + logTail: 'ERROR: Job failed: exit code 1', + steps: [] + } + ] + }) + const fetchPRCheckDetails = vi.fn().mockResolvedValue(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + repos: [{ id: 'repo-1', path: '/repo' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] }, + settings: { activeRuntimeEnvironmentId: null }, + fetchPRCheckDetails, + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi + const check = { + name: 'test: unit', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + gitlabJobId: 42 + } + + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null + }) + + await store.getState().reloadOpenCheckRunDetailsTab('wt-1::check-details::gitlab-job:42') + + expect(fetchPRCheckDetails).not.toHaveBeenCalled() + expect(loadGitLabJobLogDetailsMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/repo', repoId: 'repo-1', check }) + ) + expect(store.getState().openFiles).toContainEqual( + expect.objectContaining({ + id: 'wt-1::check-details::gitlab-job:42', + checkRunDetails: expect.objectContaining({ + loading: false, + error: null, + details: expect.objectContaining({ + jobs: [expect.objectContaining({ logTail: 'ERROR: Job failed: exit code 1' })] + }) + }) + }) + ) + }) + + // Regression for #7732: a fork MR's job lives in the source project, so reloading + // without the stored project ref requests the trace from the wrong project. + it('reloads a fork MR job tab with the stored GitLab project ref', async () => { + loadGitLabJobLogDetailsMock.mockReset() + loadGitLabJobLogDetailsMock.mockResolvedValue(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + repos: [{ id: 'repo-1', path: '/repo' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] }, + settings: { activeRuntimeEnvironmentId: null }, + fetchPRCheckDetails: vi.fn().mockResolvedValue(null), + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi + const check = { + name: 'test: unit', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + gitlabJobId: 77 + } + const projectRef = { host: 'gitlab.com', path: 'contributor/fork' } + + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null, + gitlabProjectRef: projectRef + }) + + await store.getState().reloadOpenCheckRunDetailsTab('wt-1::check-details::gitlab-job:77') + + expect(loadGitLabJobLogDetailsMock).toHaveBeenCalledWith( + expect.objectContaining({ check, projectRef }) + ) + }) + + it('keeps a stored GitLab project ref when a patch omits it', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi + const check = { + name: 'test: unit', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + gitlabJobId: 77 + } + const projectRef = { host: 'gitlab.com', path: 'contributor/fork' } + + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null, + gitlabProjectRef: projectRef + }) + store.getState().patchOpenCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: true, + error: null, + gitlabProjectRef: null + }) + + expect( + store.getState().openFiles.find((file) => file.id === 'wt-1::check-details::gitlab-job:77') + ?.checkRunDetails?.gitlabProjectRef + ).toEqual(projectRef) + }) + it('patches an open check-details tab without changing the active file', () => { const store = createEditorTabsStore() const check = { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index ace5cace0fe..b8d5faa39dd 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -14,6 +14,8 @@ import { resolveMarkdownLinkTarget } from '@/components/editor/markdown-internal import { buildCheckRunDetailsTabId, getCheckRunDetailsTabLabel, + isSameGitLabProjectRef, + type CheckRunDetailsTabPatch, type OpenCheckRunDetailsState } from '@/components/editor/check-run-details-tab' import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' @@ -78,7 +80,11 @@ import { getRepoIdFromWorktreeId, type ActiveWorktreeStateTransition } from './worktree-helpers' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getSettingsForWorktreeRuntimeOwner +} from '@/lib/worktree-runtime-owner' +import { loadGitLabJobLogDetails } from '@/runtime/gitlab-job-trace-client' import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions @@ -589,13 +595,13 @@ export type EditorSlice = { worktreeId: string, contextKey: string, check: OpenCheckRunDetailsState['check'], - state: Pick + state: CheckRunDetailsTabPatch ) => void patchOpenCheckRunDetails: ( worktreeId: string, contextKey: string, check: OpenCheckRunDetailsState['check'], - state: Pick + state: CheckRunDetailsTabPatch ) => void reloadOpenCheckRunDetailsTab: (fileId: string) => Promise openBranchAllDiffs: ( @@ -3790,7 +3796,8 @@ export const createEditorSlice: StateCreator = (s check, details: state.details, loading: state.loading, - error: state.error + error: state.error, + gitlabProjectRef: state.gitlabProjectRef ?? null } set((s) => { const existing = s.openFiles.find((f) => f.id === id) @@ -3839,26 +3846,31 @@ export const createEditorSlice: StateCreator = (s // Why: sidebar detail fetches can finish after the full-details tab is open; update the snapshot without stealing focus. patchOpenCheckRunDetails: (worktreeId, contextKey, check, state) => { const id = buildCheckRunDetailsTabId(worktreeId, check) - const nextCheckRunDetails: OpenCheckRunDetailsState = { - contextKey, - check, - details: state.details, - loading: state.loading, - error: state.error - } set((s) => { const existing = s.openFiles.find((f) => f.id === id) if (!existing?.checkRunDetails) { return s } const current = existing.checkRunDetails + // Why: the sidebar resolves the MR's project asynchronously, so an early patch + // must not blank a ref we already know. + const gitlabProjectRef = state.gitlabProjectRef ?? current.gitlabProjectRef ?? null + const nextCheckRunDetails: OpenCheckRunDetailsState = { + contextKey, + check, + details: state.details, + loading: state.loading, + error: state.error, + gitlabProjectRef + } if ( current.contextKey === nextCheckRunDetails.contextKey && current.check.status === nextCheckRunDetails.check.status && current.check.conclusion === nextCheckRunDetails.check.conclusion && current.loading === nextCheckRunDetails.loading && current.error === nextCheckRunDetails.error && - current.details === nextCheckRunDetails.details + current.details === nextCheckRunDetails.details && + isSameGitLabProjectRef(current.gitlabProjectRef ?? null, gitlabProjectRef) ) { return s } @@ -3884,22 +3896,33 @@ export const createEditorSlice: StateCreator = (s return } const { contextKey, check } = checkRunDetails - const patch = (next: Pick): void => { + const patch = (next: CheckRunDetailsTabPatch): void => { get().patchOpenCheckRunDetails(file.worktreeId, contextKey, check, next) } patch({ details: checkRunDetails.details, loading: true, error: null }) try { - const details = await get().fetchPRCheckDetails( - repo.path, - { - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo: null - }, - { repoId: repo.id } - ) + // Why: refreshing a GitLab job tab through the GitHub check-runs API returns + // null and would blank the tab the user just asked to reload. + const details = check.gitlabJobId + ? await loadGitLabJobLogDetails({ + repoPath: repo.path, + repoId: repo.id, + settings: getSettingsForWorktreeRuntimeOwner(state, file.worktreeId), + check, + // Why: a fork MR's job lives in the source project, not the repo's own. + projectRef: checkRunDetails.gitlabProjectRef ?? null + }) + : await get().fetchPRCheckDetails( + repo.path, + { + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo: null + }, + { repoId: repo.id } + ) patch({ details, loading: false, diff --git a/src/shared/ansi-escape-sequences.ts b/src/shared/ansi-escape-sequences.ts new file mode 100644 index 00000000000..1d914900c24 --- /dev/null +++ b/src/shared/ansi-escape-sequences.ts @@ -0,0 +1,27 @@ +/* eslint-disable no-control-regex -- these patterns exist to match terminal control bytes. */ + +/** OSC title/progress frames, 7-bit (ESC ]) and 8-bit (U+009D) introducers. */ +export const OSC_SEQUENCE_PATTERN = /(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c)/g +/** DCS/APC/PM/SOS string sequences terminated by ST. */ +export const STRING_SEQUENCE_PATTERN = + /(?:\u001b[P_^X]|\u0090|\u0098|\u009e|\u009f)[\s\S]*?(?:\u001b\\|\u009c)/g +/** CSI sequences (colors, cursor moves, `\u001b[0K` erase-to-EOL). */ +export const CSI_SEQUENCE_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g +/** Remaining two/three-byte escape sequences (charset selection, RIS, ...). */ +export const ESCAPE_SEQUENCE_PATTERN = /\u001b[ -/]*[0-~]/g +/** C0/C1 control bytes except tab, line feed and carriage return. */ +export const TERMINAL_CONTROL_CHARACTER_PATTERN = + /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g + +/** + * Remove ANSI escape sequences while preserving text, tabs and line breaks. + * Ordering matters: OSC/string sequences must be consumed before the generic + * escape pattern, which would otherwise eat only their introducer. + */ +export function stripAnsiEscapeSequences(value: string): string { + return value + .replace(OSC_SEQUENCE_PATTERN, '') + .replace(STRING_SEQUENCE_PATTERN, '') + .replace(CSI_SEQUENCE_PATTERN, '') + .replace(ESCAPE_SEQUENCE_PATTERN, '') +} diff --git a/src/main/github/check-job-log-tail-slice.test.ts b/src/shared/check-job-log-tail-slice.test.ts similarity index 100% rename from src/main/github/check-job-log-tail-slice.test.ts rename to src/shared/check-job-log-tail-slice.test.ts diff --git a/src/main/github/check-job-log-tail-slice.ts b/src/shared/check-job-log-tail-slice.ts similarity index 82% rename from src/main/github/check-job-log-tail-slice.ts rename to src/shared/check-job-log-tail-slice.ts index 694a5dfec99..a5a9f483b1f 100644 --- a/src/main/github/check-job-log-tail-slice.ts +++ b/src/shared/check-job-log-tail-slice.ts @@ -10,8 +10,19 @@ export const PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR = '… earlier errors …' const ERROR_LINE_PATTERN = /(?:##\[error\]|::error::|::error\b|\berror:|FAILED|exit code|ENOENT|EACCES|panic:|AssertionError)/i +// Why: this slicer runs in main (GitHub/GitLab log fetches) and in the renderer +// (defensive re-slice of traces from older remote runtimes), so no Buffer. +function utf8ByteLength(text: string): number { + let bytes = 0 + for (const character of text) { + const codePoint = character.codePointAt(0) ?? 0 + bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4 + } + return bytes +} + function applyLogTailByteCap(text: string): string { - if (Buffer.byteLength(text, 'utf8') <= PR_CHECK_LOG_TAIL_BYTES) { + if (utf8ByteLength(text) <= PR_CHECK_LOG_TAIL_BYTES) { return text } return sliceTrailingTextByUtf8Bytes(text, PR_CHECK_LOG_TAIL_BYTES) @@ -21,7 +32,7 @@ function sliceTrailingTextByUtf8Bytes(text: string, byteLimit: number): string { let byteLength = 0 const characters = Array.from(text) for (let index = characters.length - 1; index >= 0; index -= 1) { - const characterByteLength = Buffer.byteLength(characters[index] ?? '', 'utf8') + const characterByteLength = utf8ByteLength(characters[index] ?? '') if (byteLength + characterByteLength > byteLimit) { return characters.slice(index + 1).join('') } @@ -32,14 +43,13 @@ function sliceTrailingTextByUtf8Bytes(text: string, byteLimit: number): string { function joinLogExcerptWithByteCap(prefixLines: string[], recentLines: string[]): string { const prefix = prefixLines.join('\n') - const prefixByteLength = Buffer.byteLength(prefix, 'utf8') + const prefixByteLength = utf8ByteLength(prefix) if (prefixByteLength >= PR_CHECK_LOG_TAIL_BYTES) { return sliceTrailingTextByUtf8Bytes(prefix, PR_CHECK_LOG_TAIL_BYTES) } const separator = prefix.length > 0 && recentLines.length > 0 ? '\n' : '' - const recentBudget = - PR_CHECK_LOG_TAIL_BYTES - prefixByteLength - Buffer.byteLength(separator, 'utf8') + const recentBudget = PR_CHECK_LOG_TAIL_BYTES - prefixByteLength - utf8ByteLength(separator) const recentTail = sliceTrailingTextByUtf8Bytes(recentLines.join('\n'), recentBudget) return `${prefix}${separator}${recentTail}` } diff --git a/src/shared/gitlab-job-log-excerpt.test.ts b/src/shared/gitlab-job-log-excerpt.test.ts new file mode 100644 index 00000000000..d477f9f094b --- /dev/null +++ b/src/shared/gitlab-job-log-excerpt.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { gitLabJobTraceToLogExcerpt, toGitLabJobLogExcerptResult } from './gitlab-job-log-excerpt' +import { PR_CHECK_LOG_TAIL_BYTES } from './check-job-log-tail-slice' + +const CSI_ERASE = '\u001b[0K' +const RESET = '\u001b[0;m' +const CR = '\r' + +describe('gitLabJobTraceToLogExcerpt', () => { + it('keeps the visible header of a section marker and drops the marker itself', () => { + const trace = [ + `section_start:1699000000:build_script${CR}${CSI_ERASE}$ pnpm build`, + 'built in 3s', + `section_end:1699000030:build_script${CR}${CSI_ERASE}` + ].join('\n') + + const excerpt = gitLabJobTraceToLogExcerpt(trace) + + expect(excerpt).toContain('$ pnpm build') + expect(excerpt).toContain('built in 3s') + expect(excerpt).not.toContain('section_start') + expect(excerpt).not.toContain('section_end') + }) + + it('strips ANSI colour sequences', () => { + const excerpt = gitLabJobTraceToLogExcerpt(`${RESET}ERROR: Job failed${RESET}`) + + expect(excerpt).toBe('ERROR: Job failed') + expect(excerpt).not.toContain('[0;m') + }) + + it('splits carriage-return progress output so the line tail still applies', () => { + const progress = Array.from({ length: 5_000 }, (_, index) => `downloading ${index}%`).join('\r') + + const excerpt = gitLabJobTraceToLogExcerpt(`${progress}\nERROR: Job failed: exit code 1`) + + expect(excerpt).toContain('ERROR: Job failed: exit code 1') + expect(excerpt).not.toContain('downloading 0%') + expect(excerpt.length).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES) + }) + + it('bounds a multi-megabyte trace below the 1 MB runtime transport frame cap', () => { + const huge = Array.from( + { length: 60_000 }, + (_, index) => `line ${index} ${'x'.repeat(60)}` + ).join('\n') + + const excerpt = gitLabJobTraceToLogExcerpt(huge) + + expect(huge.length).toBeGreaterThan(1024 * 1024) + expect(Buffer.byteLength(excerpt, 'utf8')).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES) + expect(excerpt).toContain('line 59999') + }) + + it('preserves earlier error context buried under trailing noise', () => { + const trace = [ + ...Array.from({ length: 150 }, (_, index) => `Installing package ${index}`), + 'ERROR: Job failed: exit code 1', + ...Array.from({ length: 90 }, (_, index) => `Uploading artifact ${index}`) + ].join('\n') + + const excerpt = gitLabJobTraceToLogExcerpt(trace) + + expect(excerpt).toContain('ERROR: Job failed: exit code 1') + expect(excerpt).toContain('Uploading artifact 89') + }) + + it('bounds the raw trace before stripping so a huge log is not scanned in full', () => { + const filler = `${'x'.repeat(200)}\n`.repeat(6_000) // ~1.2 MB, above the raw cap + const excerpt = gitLabJobTraceToLogExcerpt(`head marker\n${filler}ERROR: Job failed`) + + expect(excerpt).toContain('ERROR: Job failed') + expect(excerpt).not.toContain('head marker') + }) + + it('returns an empty excerpt for a job that never produced a log', () => { + expect(gitLabJobTraceToLogExcerpt('')).toBe('') + expect(gitLabJobTraceToLogExcerpt(` \n${CSI_ERASE}\n `)).toBe('') + }) +}) + +describe('toGitLabJobLogExcerptResult', () => { + it('bounds a successful trace and passes errors through untouched', () => { + const bounded = toGitLabJobLogExcerptResult({ ok: true, trace: `${RESET}done` }) + expect(bounded).toEqual({ ok: true, trace: 'done' }) + + const failure = { ok: false, error: '403 Forbidden' } as const + expect(toGitLabJobLogExcerptResult(failure)).toBe(failure) + }) +}) diff --git a/src/shared/gitlab-job-log-excerpt.ts b/src/shared/gitlab-job-log-excerpt.ts new file mode 100644 index 00000000000..66754edb751 --- /dev/null +++ b/src/shared/gitlab-job-log-excerpt.ts @@ -0,0 +1,52 @@ +import { stripAnsiEscapeSequences } from './ansi-escape-sequences' +import { sliceCheckLogTail } from './check-job-log-tail-slice' +import type { GitLabJobTraceResult } from './gitlab-types' + +// GitLab wraps collapsible sections in `section_start::\r0K`; +// strip only through the CR so the human-readable header on the same line survives. +const SECTION_MARKER_PREFIX_PATTERN = /^section_(?:start|end):\d+:[^\r\n]*?\r/gm +// Sections without a visible header leave a marker-only line; drop it whole. +const SECTION_MARKER_LINE_PATTERN = /^section_(?:start|end):\d+:[^\r\n]*\n?/gm + +// Why: stripping makes several full passes over the input, so a multi-megabyte CI log +// would stall the main-process event loop before the tail is trimmed to 16 KB anyway. +// 512 K chars is far more than the excerpt can keep, even with markup removed. +const MAX_RAW_TRACE_CHARS = 512 * 1024 + +function rawTraceTail(trace: string): string { + if (trace.length <= MAX_RAW_TRACE_CHARS) { + return trace + } + const tail = trace.slice(trace.length - MAX_RAW_TRACE_CHARS) + // Drop the partial first line so a marker or escape cut in half cannot survive stripping. + const firstLineBreak = tail.indexOf('\n') + return firstLineBreak === -1 ? tail : tail.slice(firstLineBreak + 1) +} + +/** + * Turn a raw `glab api .../jobs/:id/trace` body into a bounded, readable excerpt. + * + * Bounding is not cosmetic: the runtime RPC transports drop frames over 1 MB + * (`MAX_WS_MESSAGE_BYTES` / `MAX_RELAY_MESSAGE_BYTES`), so a multi-megabyte CI + * log must be cut before it crosses the wire, not after. + */ +export function gitLabJobTraceToLogExcerpt(trace: string): string { + if (!trace) { + return '' + } + // Order matters: ANSI first so the erase-to-EOL sequence between the marker's CR + // and its header text is gone, CR normalisation last so the marker patterns can + // still see the CR that separates a marker from its visible header. + const readable = stripAnsiEscapeSequences(rawTraceTail(trace)) + .replace(SECTION_MARKER_PREFIX_PATTERN, '') + .replace(SECTION_MARKER_LINE_PATTERN, '') + // Why: progress output redraws with bare CR, which would otherwise make the + // whole job log a single line and defeat the line-based tail. + .replace(/\r\n?/g, '\n') + return sliceCheckLogTail(readable).trim() +} + +/** Applied in main so only the excerpt crosses IPC / the runtime RPC socket. */ +export function toGitLabJobLogExcerptResult(result: GitLabJobTraceResult): GitLabJobTraceResult { + return result.ok ? { ok: true, trace: gitLabJobTraceToLogExcerpt(result.trace) } : result +} diff --git a/src/shared/gitlab-job-trace-check-details.test.ts b/src/shared/gitlab-job-trace-check-details.test.ts new file mode 100644 index 00000000000..59cf24cbf79 --- /dev/null +++ b/src/shared/gitlab-job-trace-check-details.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { + gitLabJobCanHaveTrace, + gitLabJobTraceToCheckRunDetails +} from './gitlab-job-trace-check-details' +import { gitLabPipelineJobsToPRChecks } from './gitlab-pipeline-checks' +import type { GitLabPipelineJob } from './gitlab-types' +import type { PRCheckDetail } from './types' + +const STRINGS = { emptyTrace: 'No log is available for this GitLab job.' } + +const failedJob: PRCheckDetail = { + name: 'Component Tests: Purchase API', + status: 'completed', + conclusion: 'failure', + url: 'https://gitlab.example.test/acme/orca/-/jobs/42', + gitlabJobId: 42 +} + +describe('gitLabJobTraceToCheckRunDetails', () => { + it('renders the trace as a single job log tail', () => { + const details = gitLabJobTraceToCheckRunDetails( + failedJob, + 'FAIL src/purchase/refund.spec.ts\nERROR: Job failed: exit code 1', + STRINGS + ) + + expect(details.jobs).toHaveLength(1) + expect(details.jobs[0]?.id).toBe(42) + expect(details.jobs[0]?.conclusion).toBe('failure') + expect(details.jobs[0]?.logTail).toContain('ERROR: Job failed: exit code 1') + expect(details.summary).toBeNull() + }) + + it('copies status and conclusion from the row so the poll cache is not evicted', () => { + const details = gitLabJobTraceToCheckRunDetails(failedJob, 'boom', STRINGS) + + expect(details.status).toBe(failedJob.status) + expect(details.conclusion).toBe(failedJob.conclusion) + }) + + it('explains an empty trace instead of falling back to "no inline details"', () => { + const manualJob: PRCheckDetail = { + name: 'deploy: production', + status: 'completed', + conclusion: 'neutral', + url: null, + gitlabJobId: 77 + } + + const details = gitLabJobTraceToCheckRunDetails(manualJob, '', STRINGS) + + expect(details.jobs).toEqual([]) + expect(details.summary).toBe(STRINGS.emptyTrace) + }) + + it('re-slices a raw trace from an older remote runtime that ignored logExcerpt', () => { + const raw = Array.from({ length: 5_000 }, (_, index) => `line ${index}`).join('\n') + + const details = gitLabJobTraceToCheckRunDetails(failedJob, raw, STRINGS) + + expect(details.jobs[0]?.logTail).toContain('line 4999') + expect(details.jobs[0]?.logTail).not.toContain('line 0\n') + }) +}) + +describe('gitLabJobCanHaveTrace', () => { + // Why: GitLab answers 404 for a job that never ran, so skip the round trip. Jobs that + // may have run are still asked; main turns a missing log into an empty trace. + const cases: { status: string; expected: boolean }[] = [ + { status: 'failed', expected: true }, + { status: 'success', expected: true }, + { status: 'running', expected: true }, + { status: 'canceled', expected: true }, + { status: 'created', expected: false }, + { status: 'pending', expected: false }, + { status: 'scheduled', expected: false }, + { status: 'manual', expected: false }, + { status: 'skipped', expected: false } + ] + + for (const { status, expected } of cases) { + it(`${expected ? 'requests' : 'skips'} a trace for a ${status} job`, () => { + const job = { + id: 5, + name: 'unit', + stage: 'test', + status, + webUrl: '', + duration: null + } as unknown as GitLabPipelineJob + const [check] = gitLabPipelineJobsToPRChecks([job]) + + expect(gitLabJobCanHaveTrace(check)).toBe(expected) + }) + } +}) diff --git a/src/shared/gitlab-job-trace-check-details.ts b/src/shared/gitlab-job-trace-check-details.ts new file mode 100644 index 00000000000..fd81789bc0a --- /dev/null +++ b/src/shared/gitlab-job-trace-check-details.ts @@ -0,0 +1,70 @@ +import { gitLabJobTraceToLogExcerpt } from './gitlab-job-log-excerpt' +import type { PRCheckDetail, PRCheckRunDetails } from './types' + +export type GitLabJobTraceCheckDetailsStrings = { + /** Shown for a job with no log: never ran, waiting on a human, or log erased/expired. */ + emptyTrace: string +} + +/** + * Whether GitLab can have a trace for this job yet. + * + * Jobs that never started (`created`/`pending` -> queued) and jobs waiting on a human + * (`manual` -> neutral) or bypassed (`skipped`) have no trace, so skip the round trip. + * Other jobs may still lack a log (canceled before start, erased); main maps that 404 + * to an empty trace rather than an error. + */ +export function gitLabJobCanHaveTrace(check: PRCheckDetail): boolean { + if (check.status === 'queued') { + return false + } + return check.conclusion !== 'neutral' && check.conclusion !== 'skipped' +} + +/** + * Adapt a GitLab job trace to the provider-neutral check-details shape the Checks + * panel and the full-details tab already render. GitLab exposes one flat trace per + * job rather than GitHub's step/annotation breakdown, so it lands in a single job's + * `logTail`. + */ +export function gitLabJobTraceToCheckRunDetails( + check: PRCheckDetail, + trace: string, + strings: GitLabJobTraceCheckDetailsStrings +): PRCheckRunDetails { + // Re-slice defensively: an older remote runtime returns the raw trace because it + // does not understand the `logExcerpt` request flag. + const logTail = gitLabJobTraceToLogExcerpt(trace) + return { + name: check.name, + // Why: copying the row's own state keeps the panel's status/conclusion cache + // invalidation from evicting this entry on every poll tick. + status: check.status, + conclusion: check.conclusion, + url: check.url, + detailsUrl: check.url, + startedAt: null, + completedAt: null, + title: null, + // Why: a job with no log still needs a distinct explanation, otherwise it falls + // back to the generic "no inline details" text this feature exists to remove. + summary: logTail ? null : strings.emptyTrace, + text: null, + annotations: [], + jobs: logTail + ? [ + { + id: check.gitlabJobId ?? null, + name: check.name, + status: check.status, + conclusion: check.conclusion, + startedAt: null, + completedAt: null, + url: check.url, + logTail, + steps: [] + } + ] + : [] + } +} diff --git a/src/shared/gitlab-pipeline-checks.test.ts b/src/shared/gitlab-pipeline-checks.test.ts index 673fcf3cd16..71f2be73d8d 100644 --- a/src/shared/gitlab-pipeline-checks.test.ts +++ b/src/shared/gitlab-pipeline-checks.test.ts @@ -44,26 +44,42 @@ describe('gitLabPipelineJobsToPRChecks', () => { name: 'test: unit', status: 'completed', conclusion: 'failure', - url: 'https://gitlab.com/acme/orca/-/jobs/1' + url: 'https://gitlab.com/acme/orca/-/jobs/1', + gitlabJobId: 1 }, { name: 'deploy: deploy', status: 'completed', conclusion: 'neutral', - url: null + url: null, + gitlabJobId: 2 }, { name: 'deploy: delayed deploy', status: 'queued', conclusion: 'pending', - url: 'https://gitlab.com/acme/orca/-/jobs/3' + url: 'https://gitlab.com/acme/orca/-/jobs/3', + gitlabJobId: 3 }, { name: 'integration: external callback', status: 'queued', conclusion: 'pending', - url: 'https://gitlab.com/acme/orca/-/jobs/4' + url: 'https://gitlab.com/acme/orca/-/jobs/4', + gitlabJobId: 4 } ]) }) + + it('omits the job id when GitLab does not report a usable one', () => { + const jobs = [ + { id: 0, name: 'zero', stage: '', status: 'failed', webUrl: '', duration: null }, + { name: 'missing', stage: '', status: 'failed', webUrl: '', duration: null } + ] as unknown as GitLabPipelineJob[] + + // Why: the runtime RPC schema requires a positive int, so a falsy id must not be forwarded. + for (const check of gitLabPipelineJobsToPRChecks(jobs)) { + expect(check).not.toHaveProperty('gitlabJobId') + } + }) }) diff --git a/src/shared/gitlab-pipeline-checks.ts b/src/shared/gitlab-pipeline-checks.ts index f993be157a3..9cb9a2b1ebd 100644 --- a/src/shared/gitlab-pipeline-checks.ts +++ b/src/shared/gitlab-pipeline-checks.ts @@ -62,6 +62,8 @@ export function gitLabPipelineJobsToPRChecks(jobs: GitLabPipelineJob[]): PRCheck name: job.stage ? `${job.stage}: ${job.name}` : job.name, status: mapGitLabPipelineJobStatusToCheckStatus(job.status), conclusion: mapGitLabPipelineJobStatusToConclusion(job.status), - url: job.webUrl || null + url: job.webUrl || null, + // Truthiness, not != null: the runtime RPC schema requires a positive int. + ...(job.id ? { gitlabJobId: job.id } : {}) })) } diff --git a/src/shared/pr-checks-fix-prompt.ts b/src/shared/pr-checks-fix-prompt.ts index e31a9fbbf80..ff50a410942 100644 --- a/src/shared/pr-checks-fix-prompt.ts +++ b/src/shared/pr-checks-fix-prompt.ts @@ -82,6 +82,11 @@ export function getCheckDetailsPromptKey(check: PRCheckDetail, index: number): s if (check.workflowRunId) { return `workflow-run:${check.workflowRunId}:${check.name}` } + // Keep in step with getCheckIdentityKey / getCheckRunTabIdentity: a GitLab job + // without a web_url would otherwise key on its index and miss its loaded log. + if (check.gitlabJobId) { + return `gitlab-job:${check.gitlabJobId}:${check.name}` + } if (check.url) { return `url:${check.url}:${check.name}` } diff --git a/src/shared/repro-7732-gitlab-job-id-dropped.test.ts b/src/shared/repro-7732-gitlab-job-id-dropped.test.ts new file mode 100644 index 00000000000..4f1130f26f2 --- /dev/null +++ b/src/shared/repro-7732-gitlab-job-id-dropped.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { gitLabPipelineJobsToPRChecks } from './gitlab-pipeline-checks' +import type { GitLabPipelineJob } from './types' + +// Repro for #7732: the Checks side panel can only ask for a GitLab job trace if the mapped +// check row still carries the numeric GitLab job id (gitlab:jobTrace takes { jobId }). +function numericHandles(value: object): number[] { + return Object.values(value).filter((v): v is number => typeof v === 'number') +} + +describe('#7732 GitLab pipeline job -> check row mapping', () => { + const failedJob: GitLabPipelineJob = { + id: 42, + name: 'Purchase API Component Tests', + stage: 'Component Tests', + status: 'failed', + webUrl: 'https://gitlab.com/acme/orca/-/jobs/42', + duration: 31 + } + + it('keeps the GitLab job id so the panel can fetch the job trace', () => { + const [check] = gitLabPipelineJobsToPRChecks([failedJob]) + + expect(check.name).toBe('Component Tests: Purchase API Component Tests') + expect(check.conclusion).toBe('failure') + // The id is the only handle gitlab:jobTrace accepts; without it the expand path has nothing to send. + expect(numericHandles(check)).toContain(42) + }) +}) diff --git a/src/shared/types.ts b/src/shared/types.ts index bc88d4d6d6c..b4e1a243242 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1408,6 +1408,9 @@ export type PRCheckDetail = { url: string | null checkRunId?: number workflowRunId?: number + // Why: the GitLab job trace API is addressed by numeric job id only, so the + // Checks panel cannot load a job log without carrying it on the row. + gitlabJobId?: number } export type PRCheckAnnotation = { diff --git a/tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts b/tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts new file mode 100644 index 00000000000..c7f87dee9e3 --- /dev/null +++ b/tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts @@ -0,0 +1,160 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { openChecks } from './helpers/source-control-ai-generation' + +// Repro for #7732: expanding a GitLab pipeline job in the Checks panel must show its log, +// not "No inline details are available for this check." All fixture data is synthetic. +const FIXTURE = { + mrNumber: 4242, + jobId: 987654, + jobName: 'Purchase API Component Tests', + stage: 'Component Tests', + webUrl: 'https://gitlab.example.test/acme/orca/-/jobs/987654', + mrUrl: 'https://gitlab.example.test/acme/orca/-/merge_requests/4242', + headSha: 'e2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2e', + trace: [ + '$ pnpm test:component --project purchase-api', + 'FAIL src/purchase/refund.spec.ts', + ' AssertionError: expected refunded amount 4200 to equal 4250', + 'ERROR: Job failed: exit code 1' + ].join('\n') +} as const + +const SCREENSHOT_DIR = + process.env.ORCA_GITLAB_CHECKS_JOB_DETAILS_SCREENSHOT_DIR ?? + path.join(process.cwd(), 'test-results', 'gitlab-checks-job-details') + +// contextIsolation makes window.api non-writable, so stub at the IPC boundary in main. +async function installGitLabChecksBackend(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ ipcMain }, fx) => { + ipcMain.removeHandler('hostedReview:forBranch') + ipcMain.handle('hostedReview:forBranch', async () => ({ + provider: 'gitlab', + number: fx.mrNumber, + title: 'Add purchase API component test coverage', + state: 'open', + url: fx.mrUrl, + status: 'failure', + updatedAt: '2026-07-07T12:00:00.000Z', + mergeable: 'MERGEABLE', + headSha: fx.headSha + })) + + ipcMain.removeHandler('gitlab:workItemDetails') + ipcMain.handle('gitlab:workItemDetails', async () => ({ + item: { + id: `gitlab-mr-${fx.mrNumber}`, + type: 'mr', + number: fx.mrNumber, + title: 'Add purchase API component test coverage', + state: 'opened', + url: fx.mrUrl, + labels: [], + updatedAt: '2026-07-07T12:00:00.000Z', + author: 'e2e-bot' + }, + body: 'Synthetic MR used for #7732 repro evidence.', + comments: [], + headSha: fx.headSha, + pipelineJobs: [ + { + id: fx.jobId, + pipelineId: 55, + name: fx.jobName, + stage: fx.stage, + status: 'failed', + webUrl: fx.webUrl, + duration: 87 + } + ], + reviewers: [] + })) + + ipcMain.removeHandler('gitlab:jobTrace') + ;(globalThis as { __repro7732JobTraceCalls?: number }).__repro7732JobTraceCalls = 0 + ipcMain.handle('gitlab:jobTrace', async () => { + const g = globalThis as { __repro7732JobTraceCalls?: number } + g.__repro7732JobTraceCalls = (g.__repro7732JobTraceCalls ?? 0) + 1 + return { ok: true, trace: fx.trace } + }) + }, FIXTURE) +} + +async function readJobTraceCallCount(electronApp: ElectronApplication): Promise { + return electronApp.evaluate( + () => (globalThis as { __repro7732JobTraceCalls?: number }).__repro7732JobTraceCalls ?? 0 + ) +} + +async function linkGitLabMRToWorktree(page: Page, worktreeId: string): Promise { + await page.evaluate( + ({ worktreeId, mrNumber }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.setState((current) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(current.worktreesByRepo).map(([repoId, worktrees]) => [ + repoId, + worktrees.map((worktree) => + worktree.id === worktreeId ? { ...worktree, linkedGitLabMR: mrNumber } : worktree + ) + ]) + ), + gitStatusByWorktree: { ...current.gitStatusByWorktree, [worktreeId]: [] } + })) + }, + { worktreeId, mrNumber: FIXTURE.mrNumber } + ) +} + +test.describe('#7732 GitLab Checks panel job details', () => { + test('expanding a failed pipeline job shows its log, not "No inline details"', async ({ + orcaPage, + electronApp + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await installGitLabChecksBackend(electronApp) + + const worktreeId = await orcaPage.evaluate( + () => window.__store?.getState().activeWorktreeId ?? null + ) + if (!worktreeId) { + throw new Error('E2E fixture did not expose an active worktree') + } + // Late startup UI hydration resets the active workspace + sidebar route; let it settle first. + await orcaPage.waitForTimeout(8_000) + + const jobRow = orcaPage.getByText(`${FIXTURE.stage}: ${FIXTURE.jobName}`, { exact: true }) + for (let attempt = 0; attempt < 40 && (await jobRow.count()) === 0; attempt++) { + await linkGitLabMRToWorktree(orcaPage, worktreeId) + await openChecks(orcaPage, worktreeId) + await orcaPage.waitForTimeout(500) + } + await expect(jobRow).toBeVisible({ timeout: 15_000 }) + + mkdirSync(SCREENSHOT_DIR, { recursive: true }) + await jobRow.click() + const noDetails = orcaPage.getByText('No inline details are available for this check.') + const viewFullLogs = orcaPage.getByRole('button', { name: 'View full logs' }) + for (let attempt = 0; attempt < 20; attempt++) { + if ((await noDetails.count()) > 0 || (await viewFullLogs.count()) > 0) { + break + } + await orcaPage.waitForTimeout(500) + } + await orcaPage.screenshot({ + path: path.join(SCREENSHOT_DIR, 'gitlab-checks-job-expanded.png') + }) + + // Correct behavior: the stubbed gitlab:jobTrace log renders instead of the "no details" fallback. + expect(await readJobTraceCallCount(electronApp)).toBeGreaterThan(0) + await expect(noDetails).toHaveCount(0) + await expect(viewFullLogs).toBeVisible({ timeout: 10_000 }) + }) +}) From e59a319ffe1817bef6590a1e77c2e8ffaef1a6eb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:55:17 -0700 Subject: [PATCH 034/101] fix(sidebar): keep each project's entry-point workspace visible under "Hide sleeping" (#12257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Hide sleeping" swept each project's main workspace out of the sidebar as soon as it had no live PTY, browser tab or agent — even with "Hide default branch" off. For a project whose only row is that workspace (a folder workspace, a fresh clone, a detached-HEAD main), the entire project vanished with no in-place way back. Adds a shared `isSleepingSweepExemptWorkspace` predicate keyed on `isMainWorktree` rather than the branch name, so folder workspaces (no branch), detached-HEAD mains, and SSH rows whose head/branch are blanked while a provider is disconnected all stay put. Wired into `computeVisibleWorktreeIds` (sidebar, Cmd+1-9, workspace board), the jump palette's duplicate inline pass, and mobile's `filterWorktrees`. Ships default-on with an escape hatch: a persisted `alwaysShowDefaultBranchWorkspace` setting surfaced as "Except default branch" under "Hide sleeping". Explicit "Hide default branch" still wins, since it filters before the sleeping sweep. Mobile reads the setting but never writes it back, so a desktop opt-out can't be clobbered by a filter tap before the ui.get roundtrip lands. Combines the two PRs open against #8873. #8966's exempt set is a strict subset of this one, so its production diff was subsumed rather than ported; its jump-palette render harness and e2e spec were carried over, and are the only such coverage here. Fixes #8873 Closes #8966 Co-authored-by: Rod Boev Co-authored-by: Orca --- mobile/app/h/[hostId]/index.tsx | 11 +- .../worktree/workspace-list-sections.test.ts | 89 +++++ .../src/worktree/workspace-list-sections.ts | 17 +- mobile/src/worktree/workspace-list-types.ts | 2 + .../src/worktree/workspace-view-settings.ts | 4 + .../runtime/rpc/methods/client-ui-schemas.ts | 1 + .../runtime/rpc/methods/client-ui.test.ts | 4 +- src/renderer/src/App.tsx | 3 + .../components/WorktreeJumpPalette.test.tsx | 351 ++++++++++++++++++ .../src/components/WorktreeJumpPalette.tsx | 8 +- .../components/sidebar/FilterToggleRow.tsx | 71 ++++ .../src/components/sidebar/SidebarFilter.tsx | 73 ++-- .../sidebar/SidebarWorkspaceFilterSection.tsx | 61 +-- .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 3 + .../src/components/sidebar/WorktreeList.tsx | 12 + .../add-repo-skip-finalization.test.ts | 45 +++ .../sidebar/add-repo-skip-finalization.ts | 13 + ...branch-visible-under-hide-sleeping.test.ts | 205 ++++++++++ .../sidebar/sidebar-filter-state.test.ts | 34 ++ ...e-visible-workspace-kanban-worktree-ids.ts | 3 + .../sidebar/visible-worktrees.test.ts | 63 ++++ .../components/sidebar/visible-worktrees.ts | 35 ++ ...ktree-jump-palette-sleeping-filter.test.ts | 69 ++++ src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- src/renderer/src/lib/startup-ui-hydration.ts | 1 + src/renderer/src/store/slices/ui.test.ts | 27 ++ src/renderer/src/store/slices/ui.ts | 7 + src/shared/constants.ts | 1 + src/shared/types.ts | 2 + tests/e2e/default-branch-visibility.spec.ts | 165 ++++++++ 34 files changed, 1315 insertions(+), 105 deletions(-) create mode 100644 src/renderer/src/components/WorktreeJumpPalette.test.tsx create mode 100644 src/renderer/src/components/sidebar/FilterToggleRow.tsx create mode 100644 src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts create mode 100644 src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts create mode 100644 tests/e2e/default-branch-visibility.spec.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 24da552eafe..b71f171ceb8 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -164,7 +164,8 @@ export function HostScreen({ const [filters, setFilters] = useState({ filterRepoIds: new Set(), hideSleeping: false, - hideDefaultBranch: false + hideDefaultBranch: false, + alwaysShowDefaultBranch: true }) const [groupMode, setGroupMode] = useState('repo') const [workspaceStatuses, setWorkspaceStatuses] = useState( @@ -195,6 +196,7 @@ export function HostScreen({ sortMode: 'recent', hideSleeping: false, hideDefaultBranch: false, + alwaysShowDefaultBranch: true, filterRepoIds: [], collapsedGroups: [], workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES @@ -206,6 +208,7 @@ export function HostScreen({ sortMode, hideSleeping: filters.hideSleeping, hideDefaultBranch: filters.hideDefaultBranch, + alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false, filterRepoIds: [...filters.filterRepoIds], collapsedGroups: [...collapsedGroups], workspaceStatuses @@ -222,7 +225,8 @@ export function HostScreen({ setFilters({ filterRepoIds: new Set(next.filterRepoIds), hideSleeping: next.hideSleeping, - hideDefaultBranch: next.hideDefaultBranch + hideDefaultBranch: next.hideDefaultBranch, + alwaysShowDefaultBranch: next.alwaysShowDefaultBranch }) }, []) @@ -234,6 +238,9 @@ export function HostScreen({ if (!client) { return } + // alwaysShowDefaultBranchWorkspace is deliberately absent: mobile reads it + // but has no toggle, so echoing its local default would silently revert a + // desktop opt-out on the first filter tap before ui.get lands (#8873). const payload: WorkspaceViewSettings = { groupBy: groupModeToDesktop(next.groupMode), sortBy: next.sortMode, diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index d6d04d43d3e..d1fc267c09f 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -106,6 +106,95 @@ describe('filterWorktrees', () => { ).toEqual([featureNamedMain]) }) + it('keeps a sleeping main worktree visible under hide-sleeping by default (#8873)', () => { + const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true }) + const feature = worktree({ worktreeId: 'feature', isMainWorktree: false }) + + expect( + filterWorktrees( + [main, feature], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([main]) + }) + + it('keeps a sleeping folder workspace visible under hide-sleeping', () => { + const folder = worktree({ + workspaceKind: 'folder-workspace', + worktreeId: 'folder:workspace-1', + branch: '', + isMainWorktree: true + }) + + expect( + filterWorktrees( + [folder], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([folder]) + }) + + it('re-hides the sleeping main worktree when the desktop setting is off', () => { + const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true }) + + expect( + filterWorktrees( + [main], + { + filterRepoIds: new Set(), + hideSleeping: true, + hideDefaultBranch: false, + alwaysShowDefaultBranch: false + }, + '' + ) + ).toEqual([]) + }) + + it('falls back to the branch heuristic for hosts that omit isMainWorktree', () => { + const legacyMain = worktree({ worktreeId: 'legacy-main', branch: 'refs/heads/master' }) + + expect( + filterWorktrees( + [legacyMain], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([legacyMain]) + }) + + it('keeps a sleeping folder workspace on hosts that omit isMainWorktree', () => { + // A folder workspace has no branch, so the legacy branch heuristic can never + // recognise it — without its own arm, #8873 still reproduces on old desktops. + const legacyFolder = worktree({ + workspaceKind: 'folder-workspace', + worktreeId: 'folder:legacy-1', + branch: '' + }) + + expect( + filterWorktrees( + [legacyFolder], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([legacyFolder]) + }) + + it('lets hide-default-branch still win over the sleeping exemption', () => { + const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true }) + + expect( + filterWorktrees( + [main], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: true }, + '' + ) + ).toEqual([]) + }) + it('keeps folder workspaces when default branch hiding is enabled', () => { const folder = worktree({ workspaceKind: 'folder-workspace', diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index bdb9e4783e3..d798a7fe5fa 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -65,6 +65,19 @@ function isDefaultBranchWorkspace(w: Worktree): boolean { return branch === 'main' || branch === 'master' } +/** + * Whether "Hide sleeping" must keep this row — the project's entry point (#8873). + * Falls back to the branch heuristic for hosts that predate isMainWorktree; a + * folder workspace is always its project's entry point, and isDefaultBranchWorkspace + * rejects it by design, so it needs its own legacy arm or #8873 still reproduces. + */ +function isSleepingSweepExempt(w: Worktree, alwaysShowDefaultBranch: boolean | undefined): boolean { + if (alwaysShowDefaultBranch === false) { + return false + } + return w.isMainWorktree ?? (w.workspaceKind === 'folder-workspace' || isDefaultBranchWorkspace(w)) +} + function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree) if (mainWorktrees.length === 0) { @@ -80,7 +93,9 @@ export function filterWorktrees( ): Worktree[] { let result = worktrees.filter((w) => !w.isArchived) if (filters.hideSleeping) { - result = result.filter(isWorktreeActive) + result = result.filter( + (w) => isSleepingSweepExempt(w, filters.alwaysShowDefaultBranch) || isWorktreeActive(w) + ) } if (filters.hideDefaultBranch) { result = result.filter((w) => !isDefaultBranchWorkspace(w)) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 4dfbc74fa53..4ddb135affe 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -52,6 +52,8 @@ export type FilterState = { filterRepoIds: Set hideSleeping: boolean hideDefaultBranch: boolean + /** Absent means on: #8873's exemption must fail open on older host payloads. */ + alwaysShowDefaultBranch?: boolean } export type Section = { key: string; title: string; icon?: 'pin'; data: Worktree[] } diff --git a/mobile/src/worktree/workspace-view-settings.ts b/mobile/src/worktree/workspace-view-settings.ts index 0987915645a..32a4ac73a63 100644 --- a/mobile/src/worktree/workspace-view-settings.ts +++ b/mobile/src/worktree/workspace-view-settings.ts @@ -16,6 +16,7 @@ export type WorkspaceViewSettings = { sortBy?: 'name' | 'smart' | 'recent' | 'repo' | 'manual' hideSleepingWorkspaces?: boolean hideDefaultBranchWorkspace?: boolean + alwaysShowDefaultBranchWorkspace?: boolean filterRepoIds?: string[] collapsedGroups?: string[] workspaceStatuses?: WorkspaceStatusDefinition[] @@ -60,6 +61,7 @@ export type MobileViewState = { sortMode: MobileSortMode hideSleeping: boolean hideDefaultBranch: boolean + alwaysShowDefaultBranch: boolean filterRepoIds: string[] collapsedGroups: string[] workspaceStatuses: readonly WorkspaceStatusDefinition[] @@ -83,6 +85,8 @@ export function applyDesktopViewSettings( sortMode: sortMode ?? current.sortMode, hideSleeping: settings.hideSleepingWorkspaces ?? current.hideSleeping, hideDefaultBranch: settings.hideDefaultBranchWorkspace ?? current.hideDefaultBranch, + alwaysShowDefaultBranch: + settings.alwaysShowDefaultBranchWorkspace ?? current.alwaysShowDefaultBranch, filterRepoIds: settings.filterRepoIds ?? current.filterRepoIds, collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups, workspaceStatuses diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index af254ca02e5..0e07476b2e6 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -223,6 +223,7 @@ const UiUpdateFields = z showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(), hideCliCreatedWorkspaces: z.boolean().optional(), hideDetachedHeadWorkspaces: z.boolean().optional(), + alwaysShowDefaultBranchWorkspace: z.boolean().optional(), filterRepoIds: StringArray.optional(), collapsedGroups: StringArray.optional(), uiZoomLevel: z.number().finite().optional(), diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 3e3bbd9ba35..2a4b17b455e 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -555,7 +555,8 @@ describe('client UI RPC methods', () => { ], ['browserImportHintHidden', { browserImportHintHidden: true }], ['mobileEmulatorTabIntroDismissed', { mobileEmulatorTabIntroDismissed: true }], - ['mobileEmulatorAgentSetupDismissed', { mobileEmulatorAgentSetupDismissed: true }] + ['mobileEmulatorAgentSetupDismissed', { mobileEmulatorAgentSetupDismissed: true }], + ['alwaysShowDefaultBranchWorkspace', { alwaysShowDefaultBranchWorkspace: false }] ])('accepts %s, which the renderer persists through ui.set', async (_label, payload) => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -592,6 +593,7 @@ describe('client UI RPC methods', () => { showSleepingWorkspaces: true, hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, showDotfilesByWorktree: { 'repo::/worktree': true }, filterRepoIds: ['repo-1'], acknowledgedAgentsByPaneKey: { 'pane-1': 123 } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 81f72be49d8..9874acecd66 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -661,6 +661,7 @@ function App(): React.JSX.Element { const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) @@ -1391,6 +1392,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, // Why (#9002): activeView is deliberately NOT included here. It used to @@ -1424,6 +1426,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, acknowledgedAgentsByPaneKey diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx new file mode 100644 index 00000000000..7d84be81be1 --- /dev/null +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -0,0 +1,351 @@ +// @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 type * as ReactI18Next from 'react-i18next' +import type { Repo, Worktree } from '../../../shared/types' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import WorktreeJumpPalette from './WorktreeJumpPalette' + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }) + } +}) + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/hooks/useSettingsNavigationMetadata', () => ({ + useSettingsNavigationMetadata: () => [] +})) + +vi.mock('@/components/sidebar/StatusIndicator', () => ({ + default: () => +})) + +vi.mock('@/components/repo/RepoBadgeLabel', () => ({ + RepoBadgeMark: () => +})) + +vi.mock('@/components/cmd-j/palette-host-badge', () => ({ + getPaletteHostBadge: () => null +})) + +vi.mock('@/components/ui/command', async () => { + const React = await import('react') + return { + CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => + open ?
{children}
: null, + CommandInput: ({ + value, + onValueChange, + placeholder + }: { + value?: string + onValueChange?: (next: string) => void + placeholder?: string + }) => { + setCommandQuery = onValueChange ?? null + return ( + onValueChange?.(event.currentTarget.value)} + /> + ) + }, + CommandList: React.forwardRef(function CommandList( + { children }: { children: React.ReactNode }, + ref: React.ForwardedRef + ) { + return ( +
+ {children} +
+ ) + }), + CommandEmpty: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + CommandItem: ({ + children, + onSelect, + value + }: { + children: React.ReactNode + onSelect?: (value: string) => void + value?: string + }) => ( + + ) + } +}) + +const initialAppState = useAppStore.getInitialState() +let testRoot: Root +let testContainer: HTMLDivElement +let setCommandQuery: ((next: string) => void) | null = null + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/repos/repo-1', + displayName: 'Repo 1', + badgeColor: '#000000', + addedAt: 0 + } +} + +function makeWorktree( + id: string, + displayName: string, + overrides: Partial = {} +): Worktree { + return { + id, + repoId: 'repo-1', + path: `/tmp/${id}`, + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: false, + displayName, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderPalette(overrides: Partial): Promise { + useAppStore.setState({ + activeModal: 'worktree-palette', + activeWorktreeId: null, + repos: [makeRepo()], + tabsByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + unifiedTabsByWorktree: {}, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + // Why explicit: the sweep exemption is what these cases probe, so it must + // not ride on whatever the store default happens to be. + alwaysShowDefaultBranchWorkspace: true, + lastVisitedAtByWorktreeId: {}, + ...overrides + } as Partial) + + await act(async () => { + testRoot.render() + }) + await flushEffects() +} + +function getWorktreeRows(): string[] { + return [...testContainer.querySelectorAll('[data-command-item^="worktree:"]')].map( + (node) => node.textContent ?? '' + ) +} + +describe('WorktreeJumpPalette', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + setCommandQuery = null + useAppStore.setState(initialAppState, true) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => { + testRoot.unmount() + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + it('keeps every inactive main workspace visible when sleeping workspaces are hidden', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + const folderMain = makeWorktree('folder-main', 'Folder workspace', { + isMainWorktree: true, + branch: '' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] }, + showSleepingWorkspaces: false + }) + + expect(testContainer.textContent).toContain('Default branch workspace') + expect(testContainer.textContent).not.toContain('Feature workspace') + // Why kept: the exemption keys on isMainWorktree, not the branch name, so a + // branchless folder workspace is the project's entry point too. + expect(testContainer.textContent).toContain('Folder workspace') + }) + + it('keeps the explicit default-branch filter authoritative', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch] }, + showSleepingWorkspaces: false, + hideDefaultBranchWorkspace: true + }) + + expect(testContainer.textContent).not.toContain('Default branch workspace') + }) + + it('keeps an active non-default workspace visible when sleeping workspaces are hidden', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + const folderMain = makeWorktree('folder-main', 'Folder workspace', { + isMainWorktree: true, + branch: '' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] }, + showSleepingWorkspaces: false, + browserTabsByWorktree: { + feature: [ + { + id: 'browser-tab-1', + worktreeId: 'feature', + url: 'https://example.com', + title: 'example.com', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + ] + } + }) + + expect(testContainer.textContent).toContain('Feature workspace') + expect(testContainer.textContent).toContain('Default branch workspace') + // Why kept: same isMainWorktree exemption — folder workspaces are covered. + expect(testContainer.textContent).toContain('Folder workspace') + }) + + it('sweeps the sleeping main workspace once the exemption is opted out', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature] }, + showSleepingWorkspaces: false, + alwaysShowDefaultBranchWorkspace: false + }) + + expect(getWorktreeRows()).toEqual([]) + expect(testContainer.textContent).not.toContain('Default branch workspace') + expect(testContainer.textContent).not.toContain('Feature workspace') + }) + + it('keeps the show-sleeping baseline and empty-query ordering intact', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + const folderMain = makeWorktree('folder-main', 'Folder workspace', { + isMainWorktree: true, + branch: '' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] }, + showSleepingWorkspaces: true, + lastVisitedAtByWorktreeId: { + feature: 300, + 'default-branch': 200, + 'folder-main': 100 + } + }) + + expect(getWorktreeRows()).toEqual([ + expect.stringContaining('Feature workspace'), + expect.stringContaining('Default branch workspace'), + expect.stringContaining('Folder workspace') + ]) + }) + + it('keeps typed-query results on the full non-archived scope', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature] }, + showSleepingWorkspaces: false + }) + + expect(testContainer.textContent).not.toContain('Feature workspace') + + expect(setCommandQuery).not.toBeNull() + + await act(async () => { + setCommandQuery?.('Feature') + }) + await flushEffects() + + expect(testContainer.textContent).toContain('Feature workspace') + }) +}) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index c85a2d700e7..19e352f7669 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -31,7 +31,8 @@ import { isAutomationGeneratedWorkspace, isCliCreatedWorkspace, isDetachedHeadWorkspace, - isDefaultBranchWorkspace + isDefaultBranchWorkspace, + isSleepingSweepExemptWorkspace } from '@/components/sidebar/visible-worktrees' import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' @@ -404,6 +405,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null) const openNewBrowserTabInActiveWorkspace = useAppStore( @@ -501,6 +503,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } if ( !showSleepingWorkspaces && + // Why the exemption here too: Cmd+J re-implements the sidebar's + // filter pass, so the shared predicate is what keeps them in step. + !isSleepingSweepExemptWorkspace(worktree, alwaysShowDefaultBranchWorkspace) && isInactiveWorkspace( worktree.id, tabsByWorktree, @@ -515,6 +520,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { }), [ allWorktrees, + alwaysShowDefaultBranchWorkspace, browserTabsByWorktree, hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, diff --git a/src/renderer/src/components/sidebar/FilterToggleRow.tsx b/src/renderer/src/components/sidebar/FilterToggleRow.tsx new file mode 100644 index 00000000000..60930a19c44 --- /dev/null +++ b/src/renderer/src/components/sidebar/FilterToggleRow.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' + +/** + * Switch row shared by the two workspace-filter surfaces (the sidebar filter + * dropdown and the workspace options menu). Extracted so the two copies cannot + * drift as rows are added. + */ +export function FilterToggleRow({ + icon, + label, + ariaLabel, + checked, + onChange, + shortcutLabel, + indented = false +}: { + icon: React.ReactNode + label: string + /** Full sentence for assistive tech when `label` only reads in visual context. */ + ariaLabel?: string + checked: boolean + onChange: (next: boolean) => void + shortcutLabel?: string + /** Renders the row as a sub-option of the row above it. */ + indented?: boolean +}) { + return ( + + ) +} + +export default FilterToggleRow diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 06c445564a7..1efbf6d0016 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -23,14 +23,13 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, - DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { FilterToggleRow } from './FilterToggleRow' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { searchRepos } from '@/lib/repo-search' -import { cn } from '@/lib/utils' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import { translate } from '@/i18n/i18n' @@ -62,6 +61,10 @@ const SidebarFilter = React.memo(function SidebarFilter({ const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const repos = useAppStore((s) => s.repos) @@ -114,6 +117,9 @@ const SidebarFilter = React.memo(function SidebarFilter({ hideAutomationGeneratedWorkspaces || hideCliCreatedWorkspaces || hideDetachedHeadWorkspaces || + // Why counted: turning the exemption off is the only way this row narrows + // the list, so the badge and Reset filters must both notice it. + !alwaysShowDefaultBranchWorkspace || hasRepoFilter const activeFilterCount = (hasSleepingFilter ? 1 : 0) + @@ -121,6 +127,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ (hideAutomationGeneratedWorkspaces ? 1 : 0) + (hideCliCreatedWorkspaces ? 1 : 0) + (hideDetachedHeadWorkspaces ? 1 : 0) + + (alwaysShowDefaultBranchWorkspace ? 0 : 1) + selectedCount const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query]) @@ -136,6 +143,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ setHideAutomationGeneratedWorkspaces(false) setHideCliCreatedWorkspaces(false) setHideDetachedHeadWorkspaces(false) + setAlwaysShowDefaultBranchWorkspace(true) setFilterRepoIds([]) }, [ setShowSleepingWorkspaces, @@ -143,6 +151,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ setHideAutomationGeneratedWorkspaces, setHideCliCreatedWorkspaces, setHideDetachedHeadWorkspaces, + setAlwaysShowDefaultBranchWorkspace, setFilterRepoIds ]) @@ -212,6 +221,20 @@ const SidebarFilter = React.memo(function SidebarFilter({ onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} shortcutLabel={sleepingShortcut === 'Unassigned' ? undefined : sleepingShortcut} /> + } + label={translate( + 'auto.components.sidebar.SidebarFilter.keepDefaultBranch', + 'Except default branch' + )} + ariaLabel={translate( + 'auto.components.sidebar.SidebarFilter.keepDefaultBranchAria', + 'Keep the default branch visible while hiding sleeping workspaces' + )} + checked={alwaysShowDefaultBranchWorkspace} + onChange={setAlwaysShowDefaultBranchWorkspace} + /> } label={translate( @@ -372,50 +395,4 @@ const SidebarFilter = React.memo(function SidebarFilter({ ) }) -function FilterToggleRow({ - icon, - label, - checked, - onChange, - shortcutLabel -}: { - icon: React.ReactNode - label: string - checked: boolean - onChange: (next: boolean) => void - shortcutLabel?: string -}) { - return ( - - ) -} - export default SidebarFilter diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index 20cddcfae49..e06f81420fb 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -1,8 +1,8 @@ import React from 'react' import { CalendarClock, GitBranch, GitCommitHorizontal, Moon, SquareTerminal } from 'lucide-react' import { useAppStore } from '@/store' -import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { FilterToggleRow } from './FilterToggleRow' const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilterSection() { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) @@ -17,6 +17,10 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) return ( <> @@ -34,6 +38,20 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter checked={!showSleepingWorkspaces} onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} /> + } + label={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.keepDefaultBranch', + 'Except default branch' + )} + ariaLabel={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.keepDefaultBranchAria', + 'Keep the default branch visible while hiding sleeping workspaces' + )} + checked={alwaysShowDefaultBranchWorkspace} + onChange={setAlwaysShowDefaultBranchWorkspace} + /> } label={translate( @@ -74,45 +92,4 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter ) }) -function FilterToggleRow({ - icon, - label, - checked, - onChange -}: { - icon: React.ReactNode - label: string - checked: boolean - onChange: (next: boolean) => void -}) { - return ( - - ) -} - export default SidebarWorkspaceFilterSection diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index b087a3fb8f5..5f3f65dcea6 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -40,6 +40,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const repos = useAppStore((s) => s.repos) const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope) @@ -84,6 +85,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM hideAutomationGeneratedWorkspaces || hideCliCreatedWorkspaces || hideDetachedHeadWorkspaces || + !alwaysShowDefaultBranchWorkspace || hasRepoFilter || hasHostVisibilityFilter const activeFilterCount = @@ -92,6 +94,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM (hideAutomationGeneratedWorkspaces ? 1 : 0) + (hideCliCreatedWorkspaces ? 1 : 0) + (hideDetachedHeadWorkspaces ? 1 : 0) + + (alwaysShowDefaultBranchWorkspace ? 0 : 1) + (hasHostVisibilityFilter ? 1 : 0) + selectedCount const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index e21ccdd01e9..422b765f082 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -5222,6 +5222,7 @@ const WorktreeList = React.memo(function WorktreeList({ const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -5509,6 +5510,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, repoMap, workspaceHostScope, visibleWorkspaceHostIds, @@ -5526,6 +5528,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, workspaceHostScope, visibleWorkspaceHostIds, settings, @@ -6487,6 +6490,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, visibleWorkspaceHostIds, workspaceHostScope }), @@ -6497,6 +6501,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, visibleWorkspaceHostIds, workspaceHostScope ] @@ -6509,6 +6514,9 @@ const WorktreeList = React.memo(function WorktreeList({ ) const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) @@ -6532,6 +6540,9 @@ const WorktreeList = React.memo(function WorktreeList({ if (actions.resetHideDetachedHeadWorkspaces) { setHideDetachedHeadWorkspaces(false) } + if (actions.resetAlwaysShowDefaultBranchWorkspace) { + setAlwaysShowDefaultBranchWorkspace(true) + } if (actions.resetVisibleWorkspaceHostIds) { setVisibleWorkspaceHostIds(null) } @@ -6542,6 +6553,7 @@ const WorktreeList = React.memo(function WorktreeList({ setHideAutomationGeneratedWorkspaces, setHideCliCreatedWorkspaces, setHideDetachedHeadWorkspaces, + setAlwaysShowDefaultBranchWorkspace, setVisibleWorkspaceHostIds, filterState ]) diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts index 6a42c461926..de9c6f430cf 100644 --- a/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts @@ -34,11 +34,14 @@ function makeState(overrides: Partial): AddRepoSki filterRepoIds: [], showActiveOnly: false, hideDefaultBranchWorkspace: false, + showSleepingWorkspaces: true, + alwaysShowDefaultBranchWorkspace: true, worktreesByRepo: {}, setActiveRepo: vi.fn(), setFilterRepoIds: vi.fn(), setShowActiveOnly: vi.fn(), setHideDefaultBranchWorkspace: vi.fn(), + setAlwaysShowDefaultBranchWorkspace: vi.fn(), ...overrides } } @@ -83,6 +86,48 @@ describe('finalizeImportedRepoAfterSkip', () => { expect(state.setHideDefaultBranchWorkspace).toHaveBeenCalledWith(false) }) + it('re-enables the default-branch exemption when the import would land asleep and hidden', () => { + const state = makeState({ + showSleepingWorkspaces: false, + alwaysShowDefaultBranchWorkspace: false, + worktreesByRepo: { + 'repo-new': [ + makeWorktree({ + id: 'repo-new::/repo/main', + repoId: 'repo-new', + isMainWorktree: true, + branch: 'refs/heads/main' + }) + ] + } + }) + + finalizeImportedRepoAfterSkip(state, 'repo-new') + + expect(state.setAlwaysShowDefaultBranchWorkspace).toHaveBeenCalledWith(true) + }) + + it('leaves the default-branch exemption alone when sleeping workspaces are shown', () => { + const state = makeState({ + showSleepingWorkspaces: true, + alwaysShowDefaultBranchWorkspace: false, + worktreesByRepo: { + 'repo-new': [ + makeWorktree({ + id: 'repo-new::/repo/main', + repoId: 'repo-new', + isMainWorktree: true, + branch: 'refs/heads/main' + }) + ] + } + }) + + finalizeImportedRepoAfterSkip(state, 'repo-new') + + expect(state.setAlwaysShowDefaultBranchWorkspace).not.toHaveBeenCalled() + }) + it('still reveals the imported repo when it has no discovered worktrees yet', () => { const state = makeState({ activeRepoId: 'repo-old', diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts index 4c5e28d543b..4161f3bb6f9 100644 --- a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts +++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts @@ -6,11 +6,14 @@ export type AddRepoSkipFinalizationState = { filterRepoIds: string[] showActiveOnly: boolean hideDefaultBranchWorkspace: boolean + showSleepingWorkspaces: boolean + alwaysShowDefaultBranchWorkspace: boolean worktreesByRepo: Record setActiveRepo: (repoId: string | null) => void setFilterRepoIds: (repoIds: string[]) => void setShowActiveOnly: (value: boolean) => void setHideDefaultBranchWorkspace: (value: boolean) => void + setAlwaysShowDefaultBranchWorkspace: (value: boolean) => void } export function finalizeImportedRepoAfterSkip( @@ -37,4 +40,14 @@ export function finalizeImportedRepoAfterSkip( ) { state.setHideDefaultBranchWorkspace(false) } + // Why: with "Hide sleeping" on, a freshly imported project has no live PTY + // yet, so the opted-out exemption would leave it invisible on arrival. + if ( + importedWorktrees.length > 0 && + state.alwaysShowDefaultBranchWorkspace === false && + !state.showSleepingWorkspaces && + importedWorktrees.every((worktree) => worktree.isMainWorktree) + ) { + state.setAlwaysShowDefaultBranchWorkspace(true) + } } diff --git a/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts b/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts new file mode 100644 index 00000000000..9187dd02e13 --- /dev/null +++ b/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest' +import { + computeVisibleWorktreeIds, + isDefaultBranchWorkspace, + type SidebarFilterState +} from './visible-worktrees' +import type { Repo, Worktree } from '../../../../shared/types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' + +/** + * Repro for #8873 — "[Feature]: Display default branch". + * + * Reporter: turning "Hide default branch" OFF does not keep the repo's + * default-branch workspace in the sidebar; once it has no live PTY/browser/agent + * it counts as sleeping and "Hide sleeping" sweeps it away. There is no + * "Always display default branch" escape hatch. + */ + +function makeRepo(id: string): Repo { + return { id, path: `/${id}`, displayName: id, badgeColor: '#000', addedAt: 0 } +} + +function makeDefaultBranchWorktree(): Worktree { + return { + id: 'wt-main', + repoId: 'repo1', + path: '/tmp/repo1', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } +} + +const repoMap = new Map([['repo1', makeRepo('repo1')]]) + +type VisibleOptions = Parameters[2] + +function visibleOptions(overrides: Partial = {}): VisibleOptions { + return { + filterRepoIds: [], + showSleepingWorkspaces: true, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + worktreeIdsWithLiveAgent: new Set(), + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, + hideDetachedHeadWorkspaces: false, + repoMap, + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, + worktreeLineageById: {}, + ...overrides + } +} + +describe('#8873 default-branch workspace under "Hide sleeping"', () => { + it('is genuinely the default-branch row the "Hide default branch" toggle targets', () => { + expect(isDefaultBranchWorkspace(makeDefaultBranchWorktree())).toBe(true) + }) + + it('stays in the sidebar when it is sleeping and "Hide sleeping" is on', () => { + const worktree = makeDefaultBranchWorktree() + + const visible = computeVisibleWorktreeIds({ repo1: [worktree] }, [worktree.id], { + ...visibleOptions({ + // "Hide sleeping" ON, "Hide default branch" OFF — exactly the reporter's setup. + showSleepingWorkspaces: false, + hideDefaultBranchWorkspace: false + }) + }) + + expect(visible).toEqual([worktree.id]) + }) + + it('exposes an opt-in that keeps the default branch visible under "Hide sleeping"', () => { + // The issue asks for an "Always display default branch" flag. Prove the + // sidebar filter contract has no such knob today. + const filterKeys: readonly (keyof SidebarFilterState)[] = [ + 'showSleepingWorkspaces', + 'filterRepoIds', + 'hideDefaultBranchWorkspace', + 'hideAutomationGeneratedWorkspaces', + 'hideCliCreatedWorkspaces', + 'hideDetachedHeadWorkspaces', + 'alwaysShowDefaultBranchWorkspace', + 'visibleWorkspaceHostIds', + 'workspaceHostScope' + ] + const optionKeys = Object.keys(visibleOptions()) + + const alwaysShowKnob = [...filterKeys, ...optionKeys].find((key) => + /always.*default|default.*always|pinDefaultBranch/i.test(key) + ) + + expect(alwaysShowKnob).toBeDefined() + }) +}) + +describe('the "Hide sleeping" exemption for project entry-point rows', () => { + function visible(worktrees: Worktree[], overrides: Partial): string[] { + return computeVisibleWorktreeIds( + { repo1: worktrees }, + worktrees.map((w) => w.id), + visibleOptions({ showSleepingWorkspaces: false, ...overrides }) + ) + } + + it('re-hides the sleeping default branch when the user opts out', () => { + const worktree = makeDefaultBranchWorktree() + + expect(visible([worktree], { alwaysShowDefaultBranchWorkspace: false })).toEqual([]) + }) + + it('keeps the sleeping default branch when the option is explicitly on', () => { + const worktree = makeDefaultBranchWorktree() + + expect(visible([worktree], { alwaysShowDefaultBranchWorkspace: true })).toEqual([worktree.id]) + }) + + it('lets an explicit "Hide default branch" still win over the exemption', () => { + const worktree = makeDefaultBranchWorktree() + + expect( + visible([worktree], { + hideDefaultBranchWorkspace: true, + alwaysShowDefaultBranchWorkspace: true + }) + ).toEqual([]) + }) + + it('still sweeps sleeping non-main workspaces', () => { + const feature: Worktree = { + ...makeDefaultBranchWorktree(), + id: 'wt-feature', + branch: 'refs/heads/feature', + isMainWorktree: false + } + + expect(visible([feature], { alwaysShowDefaultBranchWorkspace: true })).toEqual([]) + }) + + it('keeps a sleeping folder workspace, which has no sibling row to fall back to', () => { + // Folder-mode projects are main worktrees with an empty branch/head, so the + // default-branch predicate rejects them; sweeping them drops the whole project. + const folder: Worktree = { + ...makeDefaultBranchWorktree(), + id: 'wt-folder', + branch: '', + head: '' + } + expect(isDefaultBranchWorkspace(folder)).toBe(false) + + expect(visible([folder], { alwaysShowDefaultBranchWorkspace: true })).toEqual([folder.id]) + }) + + it('keeps a sleeping detached-HEAD main worktree', () => { + const detachedMain: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-detached', branch: '' } + + expect(visible([detachedMain], { alwaysShowDefaultBranchWorkspace: true })).toEqual([ + detachedMain.id + ]) + }) + + it('lets "Hide detached HEAD" still win over the exemption', () => { + const detachedMain: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-detached', branch: '' } + + expect( + visible([detachedMain], { + hideDetachedHeadWorkspaces: true, + alwaysShowDefaultBranchWorkspace: true + }) + ).toEqual([]) + }) + + it('keeps every project’s entry point, not just the first', () => { + const mainA = makeDefaultBranchWorktree() + const mainB: Worktree = { ...makeDefaultBranchWorktree(), id: 'wt-main-b' } + + expect(visible([mainA, mainB], {})).toEqual([mainA.id, mainB.id]) + }) + + it('does not flicker out while an SSH host is offline', () => { + // A disconnected SSH provider re-synthesizes persisted worktrees with empty + // head/branch (orca-runtime.ts) while isMainWorktree stays a path compare — + // and the dead PTYs make the row read as sleeping at exactly that moment. + const awake = makeDefaultBranchWorktree() + const offline: Worktree = { ...awake, head: '', branch: '' } + + expect(visible([offline], {})).toEqual([offline.id]) + expect(visible([awake], {})).toEqual([awake.id]) + }) +}) diff --git a/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts index 0d27355c45a..317f0e6204e 100644 --- a/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts @@ -38,6 +38,7 @@ function filterState(overrides: Partial = {}): FilterState { hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, workspaceHostScope: 'all', ...overrides } @@ -97,6 +98,17 @@ describe('sidebarHasActiveFilters', () => { expect(sidebarHasActiveFilters(filterState({ filterRepoIds: ['repo1'] }))).toBe(true) }) + it('counts an opted-out default-branch exemption as an active filter', () => { + expect(sidebarHasActiveFilters(filterState({ alwaysShowDefaultBranchWorkspace: false }))).toBe( + true + ) + }) + + it('treats a missing default-branch exemption as the default, not a filter', () => { + const { alwaysShowDefaultBranchWorkspace: _omitted, ...withoutFlag } = filterState() + expect(sidebarHasActiveFilters(withoutFlag)).toBe(false) + }) + it('returns true when only host visibility is narrowed', () => { expect(sidebarHasActiveFilters(filterState({ visibleWorkspaceHostIds: ['local'] }))).toBe(true) }) @@ -111,6 +123,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) }) @@ -126,6 +139,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) }) @@ -140,6 +154,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: true, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) }) @@ -152,6 +167,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: true, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) }) @@ -164,6 +180,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: true, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) }) @@ -189,10 +206,26 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: true }) }) + it('flags only the default-branch exemption for reset when it is the sole filter', () => { + expect( + computeClearFilterActions(filterState({ alwaysShowDefaultBranchWorkspace: false })) + ).toEqual({ + resetShowSleepingWorkspaces: false, + resetFilterRepoIds: false, + resetHideDefaultBranchWorkspace: false, + resetHideAutomationGeneratedWorkspaces: false, + resetHideCliCreatedWorkspaces: false, + resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: true, + resetVisibleWorkspaceHostIds: false + }) + }) + it('flags every active filter simultaneously', () => { expect( computeClearFilterActions( @@ -211,6 +244,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: true, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: true }) }) diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 15e3ec6de43..0e565018667 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -22,6 +22,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) const settings = useAppStore((s) => s.settings) @@ -61,6 +62,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, repoMap, workspaceHostScope, visibleWorkspaceHostIds, @@ -79,6 +81,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, workspaceHostScope, visibleWorkspaceHostIds, settings, diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index f54dcef60e7..ee212e44ec9 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -553,6 +553,69 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([parent.id, child.id]) }) + it('gives a sleeping-exempt main its cached-sort slot, not a position at the end', () => { + // #8873: the exempted main enters `all` and is sorted with everyone else, + // so Cmd+1-9 numbers it where the sidebar actually renders it. + const awakeA = makeWorktree('awake-a') + const main = { ...makeWorktree('main'), isMainWorktree: true } + const awakeB = makeWorktree('awake-b') + + const options = { + showSleepingWorkspaces: false, + tabsByWorktree: { + [awakeA.id]: [makeTab('t-a', awakeA.id, 'p-a')], + [awakeB.id]: [makeTab('t-b', awakeB.id, 'p-b')] + }, + ptyIdsByTabId: { 't-a': ['p-a'], 't-b': ['p-b'] } + } + const byRepo = { repo1: [awakeA, main, awakeB] } + const sortedIds = [awakeA.id, main.id, awakeB.id] + + expect( + computeVisibleWorktreeIds( + byRepo, + sortedIds, + visibleOptions({ ...options, alwaysShowDefaultBranchWorkspace: true }) + ) + ).toEqual([awakeA.id, main.id, awakeB.id]) + + expect( + computeVisibleWorktreeIds( + byRepo, + sortedIds, + visibleOptions({ ...options, alwaysShowDefaultBranchWorkspace: false }) + ) + ).toEqual([awakeA.id, awakeB.id]) + }) + + it('leaves lineage ordering untouched when the exempted main is also a parent', () => { + // The parent used to arrive via addVisibleLineageAncestors and now arrives + // on its own; either way it must render immediately above its child. + const parent = { ...makeWorktree('parent'), isMainWorktree: true } + const child = makeWorktree('child') + const sibling = makeWorktree('sibling') + const lineage = makeWorktreeLineage(child, parent) + + const run = (alwaysShowDefaultBranchWorkspace: boolean): string[] => + computeVisibleWorktreeIds( + { repo1: [parent, child, sibling] }, + [sibling.id, child.id, parent.id], + visibleOptions({ + showSleepingWorkspaces: false, + alwaysShowDefaultBranchWorkspace, + tabsByWorktree: { + [child.id]: [makeTab('t-child', child.id, 'p-child')], + [sibling.id]: [makeTab('t-sib', sibling.id, 'p-sib')] + }, + ptyIdsByTabId: { 't-child': ['p-child'], 't-sib': ['p-sib'] }, + worktreeLineageById: { [child.id]: lineage } + }) + ) + + expect(run(true)).toEqual([sibling.id, parent.id, child.id]) + expect(run(false)).toEqual(run(true)) + }) + it('includes a filtered parent from resolved inline lineage when hydration has no side-map entry', () => { const parent = makeWorktree('parent') const child = makeWorktree('child') diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 9c44d8587e3..10aa72bcf1f 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -35,6 +35,25 @@ export function isDefaultBranchWorkspace(worktree: Worktree): boolean { return worktree.isMainWorktree && worktree.branch.trim() !== '' } +/** + * Whether the "Hide sleeping" sweep must keep this row (#8873). + * + * Why isMainWorktree and not isDefaultBranchWorkspace: the project's primary + * checkout is the repo's only guaranteed entry point. Folder workspaces and + * detached-HEAD mains fail the default-branch predicate yet often have no + * sibling row at all, so sweeping them drops the entire project out of the + * sidebar, Cmd+J and the board with no way back except changing a filter. + * + * Why shared: the sidebar pipeline and the jump palette both apply this, and a + * second copy is how the two surfaces drift. + */ +export function isSleepingSweepExemptWorkspace( + worktree: Worktree, + alwaysShowDefaultBranchWorkspace: boolean | undefined +): boolean { + return alwaysShowDefaultBranchWorkspace !== false && worktree.isMainWorktree +} + export function isAutomationGeneratedWorkspace(worktree: Worktree): boolean { return worktree.automationProvenance?.kind === 'created-by-automation' } @@ -63,6 +82,8 @@ export type SidebarFilterState = { hideAutomationGeneratedWorkspaces: boolean hideCliCreatedWorkspaces: boolean hideDetachedHeadWorkspaces: boolean + /** Keeps each project's main workspace out of the "Hide sleeping" sweep; absent means on. */ + alwaysShowDefaultBranchWorkspace?: boolean visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null workspaceHostScope?: ExecutionHostScope } @@ -84,6 +105,9 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { state.hideAutomationGeneratedWorkspaces || state.hideCliCreatedWorkspaces || state.hideDetachedHeadWorkspaces || + // Why: turning this off is the only way to narrow the list below the + // default, so Clear Filters must be able to undo it like any other filter. + state.alwaysShowDefaultBranchWorkspace === false || state.visibleWorkspaceHostIds != null || (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) ) @@ -98,6 +122,7 @@ export type ClearFilterActions = { resetHideAutomationGeneratedWorkspaces: boolean resetHideCliCreatedWorkspaces: boolean resetHideDetachedHeadWorkspaces: boolean + resetAlwaysShowDefaultBranchWorkspace: boolean resetVisibleWorkspaceHostIds: boolean } @@ -119,6 +144,7 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, resetHideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, resetHideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces, + resetAlwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace === false, resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null || (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) @@ -155,6 +181,11 @@ export function computeVisibleWorktreeIds( hideAutomationGeneratedWorkspaces: boolean hideCliCreatedWorkspaces: boolean hideDetachedHeadWorkspaces: boolean + // Why optional here, against the "why required" rule above: omitting it + // must fail *open*. A caller that forgets the flag then shows an extra row + // instead of silently re-hiding the project's entry point, which is the + // exact regression #8873 reports. + alwaysShowDefaultBranchWorkspace?: boolean repoMap: Map workspaceHostScope: ExecutionHostScope visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null @@ -211,8 +242,11 @@ export function computeVisibleWorktreeIds( } if (!opts.showSleepingWorkspaces) { + // Why no !hideDefaultBranchWorkspace term: that filter already ran above, so + // an explicit hide still wins over the exemption. all = all.filter( (w) => + isSleepingSweepExemptWorkspace(w, opts.alwaysShowDefaultBranchWorkspace) || !isInactiveWorkspace( w.id, opts.tabsByWorktree, @@ -370,6 +404,7 @@ export function getVisibleWorktreeIds(): string[] { hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace, repoMap, workspaceHostScope: state.workspaceHostScope, visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, diff --git a/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts b/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts new file mode 100644 index 00000000000..05ac5130e36 --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { isSleepingSweepExemptWorkspace } from './sidebar/visible-worktrees' +import type { Worktree } from '../../../shared/types' + +const source = readFileSync(join(__dirname, 'WorktreeJumpPalette.tsx'), 'utf8') + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'wt-main', + repoId: 'repo1', + path: '/tmp/repo1', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +describe('Cmd+J empty-query "Hide sleeping" pass (#8873)', () => { + // Why source-level: the palette re-implements the sidebar's filter pass + // inline, so the only structural guarantee that the two agree is that both + // call the shared predicate. A behavioral copy here would not catch a + // hand-rolled duplicate creeping back in. + it('routes the sleeping sweep through the shared exemption predicate', () => { + const start = source.indexOf('const emptyQueryVisibleWorktrees = useMemo(') + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf('const { visibleWorktreesForState', start) + const filterPass = source.slice(start, end) + + expect(filterPass).toContain( + '!isSleepingSweepExemptWorkspace(worktree, alwaysShowDefaultBranchWorkspace)' + ) + expect(filterPass).toContain('alwaysShowDefaultBranchWorkspace,') + }) + + it('reads the flag from the same store field the sidebar uses', () => { + expect(source).toContain( + 'const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)' + ) + }) + + it('exempts a project entry point by default and honours an explicit opt-out', () => { + const main = makeWorktree() + + expect(isSleepingSweepExemptWorkspace(main, undefined)).toBe(true) + expect(isSleepingSweepExemptWorkspace(main, true)).toBe(true) + expect(isSleepingSweepExemptWorkspace(main, false)).toBe(false) + }) + + it('never exempts a non-main workspace', () => { + const feature = makeWorktree({ id: 'wt-feature', isMainWorktree: false }) + + expect(isSleepingSweepExemptWorkspace(feature, true)).toBe(false) + expect(isSleepingSweepExemptWorkspace(feature, undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d0511b2ca4e..7b55bf13367 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4309,7 +4309,9 @@ "ee240a39eb": "Edit filters", "automationCreated": "Hide automation-created", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "Hide detached HEAD", + "keepDefaultBranch": "Except default branch", + "keepDefaultBranchAria": "Keep the default branch visible while hiding sleeping workspaces" }, "SidebarHeader": { "25a95899c9": "Add Project", @@ -4374,7 +4376,9 @@ "82594419ba": "Filters", "automationCreated": "Hide automation-created", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "Hide detached HEAD", + "keepDefaultBranch": "Except default branch", + "keepDefaultBranchAria": "Keep the default branch visible while hiding sleeping workspaces" }, "sidebarHostOptions": { "3e102f111c": "All hosts", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9797580ad75..904beaa9d16 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4227,7 +4227,9 @@ "ee240a39eb": "Editar filtros", "automationCreated": "Ocultar creados por automatizaciones", "cliCreated": "Hide CLI-created", - "detachedHead": "Ocultar HEAD desacoplado" + "detachedHead": "Ocultar HEAD desacoplado", + "keepDefaultBranch": "Excepto la rama predeterminada", + "keepDefaultBranchAria": "Mantener visible la rama predeterminada al ocultar los espacios de trabajo en reposo" }, "SidebarHeader": { "92154beb7e": "Nuevo espacio de trabajo", @@ -4292,7 +4294,9 @@ "82594419ba": "Filtros", "automationCreated": "Ocultar creados por automatizaciones", "cliCreated": "Hide CLI-created", - "detachedHead": "Ocultar HEAD desacoplado" + "detachedHead": "Ocultar HEAD desacoplado", + "keepDefaultBranch": "Excepto la rama predeterminada", + "keepDefaultBranchAria": "Mantener visible la rama predeterminada al ocultar los espacios de trabajo en reposo" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Diseño de actividad del Agent", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index eab737f2541..3d5f8588d72 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4208,7 +4208,9 @@ "ee240a39eb": "フィルターの編集", "automationCreated": "自動化で作成されたワークスペースを非表示", "cliCreated": "Hide CLI-created", - "detachedHead": "分離HEADを非表示" + "detachedHead": "分離HEADを非表示", + "keepDefaultBranch": "デフォルトのブランチを除く", + "keepDefaultBranchAria": "スリープ中のワークスペースを非表示にしてもデフォルトのブランチは表示したままにする" }, "SidebarHeader": { "92154beb7e": "新規ワークスペース", @@ -4273,7 +4275,9 @@ "82594419ba": "フィルター", "automationCreated": "自動化で作成されたワークスペースを非表示", "cliCreated": "Hide CLI-created", - "detachedHead": "分離HEADを非表示" + "detachedHead": "分離HEADを非表示", + "keepDefaultBranch": "デフォルトのブランチを除く", + "keepDefaultBranchAria": "スリープ中のワークスペースを非表示にしてもデフォルトのブランチは表示したままにする" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent アクティビティのレイアウト", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6e49c537664..aa4bc49ce93 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4208,7 +4208,9 @@ "ee240a39eb": "필터 편집", "automationCreated": "자동화로 생성된 워크스페이스 숨기기", "cliCreated": "Hide CLI-created", - "detachedHead": "분리된 HEAD 숨기기" + "detachedHead": "분리된 HEAD 숨기기", + "keepDefaultBranch": "기본 브랜치는 제외", + "keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시" }, "SidebarHeader": { "92154beb7e": "새로운 워크스페이스", @@ -4273,7 +4275,9 @@ "82594419ba": "필터", "automationCreated": "자동화로 생성된 워크스페이스 숨기기", "cliCreated": "Hide CLI-created", - "detachedHead": "분리된 HEAD 숨기기" + "detachedHead": "분리된 HEAD 숨기기", + "keepDefaultBranch": "기본 브랜치는 제외", + "keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent 활동 레이아웃", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 10f601cfed2..05a13abd266 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4220,7 +4220,9 @@ "ee240a39eb": "编辑筛选条件", "automationCreated": "隐藏自动化创建的工作区", "cliCreated": "Hide CLI-created", - "detachedHead": "隐藏分离 HEAD" + "detachedHead": "隐藏分离 HEAD", + "keepDefaultBranch": "默认分支除外", + "keepDefaultBranchAria": "隐藏休眠工作区时仍显示默认分支" }, "SidebarHeader": { "92154beb7e": "新工作区", @@ -4285,7 +4287,9 @@ "82594419ba": "筛选条件", "automationCreated": "隐藏自动化创建的工作区", "cliCreated": "Hide CLI-created", - "detachedHead": "隐藏分离 HEAD" + "detachedHead": "隐藏分离 HEAD", + "keepDefaultBranch": "默认分支除外", + "keepDefaultBranchAria": "隐藏休眠工作区时仍显示默认分支" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "智能体活动布局", diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index 6daa375c8ed..aeb14f9c73e 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -51,6 +51,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState hideDefaultBranchWorkspace: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, hideAutomationGeneratedWorkspaces: false, filterRepoIds: [], collapsedGroups: [], diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 2e0e48546e2..dbd81acee9f 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -721,6 +721,33 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().showSleepingWorkspaces).toBe(true) }) + it('defaults the default-branch sleeping exemption to on', () => { + expect(getDefaultUIState().alwaysShowDefaultBranchWorkspace).toBe(true) + expect(createUIStore().getState().alwaysShowDefaultBranchWorkspace).toBe(true) + }) + + it('treats a legacy profile with no default-branch exemption key as opted in', () => { + // Why: profiles written before #8873 are exactly the ones showing the bug, + // so an absent key must hydrate to on rather than silently re-hiding main. + const store = createUIStore() + const legacy = makePersistedUI() + delete (legacy as Partial).alwaysShowDefaultBranchWorkspace + + store.getState().hydratePersistedUI(legacy, 'startup') + + expect(store.getState().alwaysShowDefaultBranchWorkspace).toBe(true) + }) + + it('preserves an explicit default-branch exemption opt-out on hydration', () => { + const store = createUIStore() + + store + .getState() + .hydratePersistedUI(makePersistedUI({ alwaysShowDefaultBranchWorkspace: false }), 'startup') + + expect(store.getState().alwaysShowDefaultBranchWorkspace).toBe(false) + }) + it('defaults workspace host scope to all hosts', () => { expect(getDefaultUIState().workspaceHostScope).toBe('all') expect(createUIStore().getState().workspaceHostScope).toBe('all') diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 625a8e9aba0..d926a100f2f 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -875,6 +875,8 @@ export type UISlice = { setHideCliCreatedWorkspaces: (v: boolean) => void hideDetachedHeadWorkspaces: boolean setHideDetachedHeadWorkspaces: (v: boolean) => void + alwaysShowDefaultBranchWorkspace: boolean + setAlwaysShowDefaultBranchWorkspace: (v: boolean) => void showDotfilesByWorktree: Record setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void toggleShowDotfilesForWorktree: (worktreeId: string) => void @@ -2053,6 +2055,8 @@ export const createUISlice: StateCreator = (set, get) setHideCliCreatedWorkspaces: (v) => set({ hideCliCreatedWorkspaces: v }), hideDetachedHeadWorkspaces: false, setHideDetachedHeadWorkspaces: (v) => set({ hideDetachedHeadWorkspaces: v }), + alwaysShowDefaultBranchWorkspace: true, + setAlwaysShowDefaultBranchWorkspace: (v) => set({ alwaysShowDefaultBranchWorkspace: v }), showDotfilesByWorktree: {}, setShowDotfilesForWorktree: (worktreeId, showDotfiles) => @@ -2459,6 +2463,9 @@ export const createUISlice: StateCreator = (set, get) hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, hideCliCreatedWorkspaces: ui.hideCliCreatedWorkspaces === true, hideDetachedHeadWorkspaces: ui.hideDetachedHeadWorkspaces === true, + // Why !== false: profiles written before #8873 have no key, and they are + // precisely the ones showing the bug, so absence must mean "exempt". + alwaysShowDefaultBranchWorkspace: ui.alwaysShowDefaultBranchWorkspace !== false, showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), // Why: startup hydrates UI before repo catalogs, so defer repo-filter validation to the all-host refresh. filterRepoIds: diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9645d3ba3b6..80fca73c828 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -479,6 +479,7 @@ export function getDefaultUIState(): PersistedUIState { hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, showDotfilesByWorktree: {}, filterRepoIds: [], collapsedGroups: [], diff --git a/src/shared/types.ts b/src/shared/types.ts index b4e1a243242..1ba5aeb7919 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3396,6 +3396,8 @@ export type PersistedUIState = { hideCliCreatedWorkspaces?: boolean /** Hide workspaces sitting on a detached HEAD; folder workspaces (no head at all) are unaffected. */ hideDetachedHeadWorkspaces?: boolean + /** Keep each project's main workspace out of the "Hide sleeping" sweep. Absent means on (#8873). */ + alwaysShowDefaultBranchWorkspace?: boolean /** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */ showDotfilesByWorktree?: Record filterRepoIds: string[] diff --git a/tests/e2e/default-branch-visibility.spec.ts b/tests/e2e/default-branch-visibility.spec.ts new file mode 100644 index 00000000000..166dc419431 --- /dev/null +++ b/tests/e2e/default-branch-visibility.spec.ts @@ -0,0 +1,165 @@ +/** + * Regression #8873: with "Hide sleeping" on, the project's main workspace must + * stay in the sidebar — it is the only guaranteed way back into the project — + * while a sleeping feature workspace is still swept. + */ + +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { worktreeRow } from './worktree-row-locators' + +type SidebarVisibilityScenario = { + defaultBranchId: string + featureId: string +} + +async function seedSidebarVisibilityScenario(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Sidebar visibility E2E needs a seeded repo') + } + + const currentWorktree = (state.worktreesByRepo[repo.id] ?? [])[0] + if (!currentWorktree) { + throw new Error('Sidebar visibility E2E needs a seeded worktree') + } + + const defaultBranchId = 'e2e-default-branch-visibility-main' + const featureId = 'e2e-default-branch-visibility-feature' + const currentId = currentWorktree.id + + store.setState((current) => ({ + worktreesByRepo: { + ...current.worktreesByRepo, + [repo.id]: [ + { + ...currentWorktree, + id: currentId, + displayName: 'Current workspace', + isMainWorktree: false, + branch: 'refs/heads/current', + lastActivityAt: 3 + }, + { + ...currentWorktree, + id: defaultBranchId, + displayName: 'Default branch workspace', + isMainWorktree: true, + branch: 'refs/heads/main', + lastActivityAt: 2 + }, + { + ...currentWorktree, + id: featureId, + displayName: 'Feature workspace', + isMainWorktree: false, + branch: 'refs/heads/feature', + lastActivityAt: 1 + } + ] + }, + tabsByWorktree: { + ...current.tabsByWorktree, + [defaultBranchId]: [], + [featureId]: [] + }, + browserTabsByWorktree: { + ...current.browserTabsByWorktree, + [defaultBranchId]: [], + [featureId]: [] + } + })) + + const nextState = store.getState() + nextState.setActiveView('terminal') + nextState.setSidebarOpen(true) + nextState.setGroupBy('none') + nextState.setSortBy('recent') + nextState.setShowActiveOnly(false) + nextState.setFilterRepoIds([]) + + return { defaultBranchId, featureId } + }) +} + +test.describe('Default branch visibility', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('keeps the default branch visible when sleeping workspaces are hidden', async ({ + orcaPage + }) => { + const { defaultBranchId, featureId } = await seedSidebarVisibilityScenario(orcaPage) + const defaultBranchRow = worktreeRow(orcaPage, defaultBranchId) + const featureRow = worktreeRow(orcaPage, featureId) + + // Poll rather than set once: hydration can land after the seed and reset the filters. + await expect + .poll(() => + orcaPage.evaluate( + ({ defaultBranchId, featureId }) => { + const state = window.__store?.getState() + state?.setShowSleepingWorkspaces(false) + state?.setHideDefaultBranchWorkspace(false) + state?.setAlwaysShowDefaultBranchWorkspace(true) + const featureTabs = state?.tabsByWorktree[featureId] ?? [] + return { + alwaysShowDefaultBranchWorkspace: state?.alwaysShowDefaultBranchWorkspace ?? null, + defaultBranchTabs: state?.tabsByWorktree[defaultBranchId]?.length ?? 0, + featureBrowserTabs: state?.browserTabsByWorktree[featureId]?.length ?? 0, + featureHasLivePty: featureTabs.some( + (tab) => (state?.ptyIdsByTabId[tab.id] ?? []).length > 0 + ), + featureTabs: featureTabs.length, + hideDefaultBranchWorkspace: state?.hideDefaultBranchWorkspace ?? null, + showSleepingWorkspaces: state?.showSleepingWorkspaces ?? null + } + }, + { defaultBranchId, featureId } + ) + ) + .toEqual({ + alwaysShowDefaultBranchWorkspace: true, + defaultBranchTabs: 0, + featureBrowserTabs: 0, + featureHasLivePty: false, + featureTabs: 0, + hideDefaultBranchWorkspace: false, + showSleepingWorkspaces: false + }) + + await expect(defaultBranchRow).toBeVisible() + await expect(defaultBranchRow).toContainText('Default branch workspace') + await expect(featureRow).toHaveCount(0) + + // Opting out of the exemption is the only way back to the pre-#8873 sweep. + await orcaPage.evaluate(() => { + window.__store?.getState().setAlwaysShowDefaultBranchWorkspace(false) + }) + + await expect(defaultBranchRow).toHaveCount(0) + + await orcaPage.evaluate(() => { + window.__store?.getState().setAlwaysShowDefaultBranchWorkspace(true) + }) + + await expect(defaultBranchRow).toBeVisible() + + // The explicit hide filter still outranks the exemption. + await orcaPage.evaluate(() => { + window.__store?.getState().setHideDefaultBranchWorkspace(true) + }) + + await expect(defaultBranchRow).toHaveCount(0) + }) +}) From dc0cb1806a376216ff8015ccead50e16265b3348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=99=B8=EA=B3=84=EA=B3=B5=EB=A3=A1?= <56211193+chucoding@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:00:02 +0900 Subject: [PATCH 035/101] feat(repo-icon): expand the project emoji picker (#7989) (#12058) Replace the hardcoded 12-emoji grid in the repo icon settings with the full searchable, category-navigable emoji picker, reusing the existing emoji-picker-react dependency in a lazily-loaded chunk. Every pick is re-validated through sanitizeRepoIcon, so an over-cap ZWJ/skin-tone sequence surfaces a toast instead of silently no-op'ing. Skin tones stay selectable. Maintainer follow-up: use lazyWithRetry so a failed chunk cannot permanently blank the Settings page, disable autoFocusSearch on this inline picker, scope font-family to descendants (the library sets sans-serif on every child), cover the uncovered dark-mode picker variables, match the app scrollbar, and assert the result in the DOM per tests/e2e/AGENTS.md. New strings are translated in es/ja/ko/zh. Co-authored-by: chucoding --- src/renderer/src/assets/main.css | 24 ++++ .../RepositoryIconEmojiPicker.test.tsx | 129 ++++++++++++++++++ .../settings/RepositoryIconEmojiPicker.tsx | 76 +++++++++++ .../settings/RepositoryIconTabs.tsx | 34 ++--- src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- tests/e2e/repo-icon-emoji-picker.spec.ts | 80 +++++++++++ 10 files changed, 343 insertions(+), 25 deletions(-) create mode 100644 src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx create mode 100644 src/renderer/src/components/settings/RepositoryIconEmojiPicker.tsx create mode 100644 tests/e2e/repo-icon-emoji-picker.spec.ts diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 47d81257dde..85d6a2c116a 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1648,6 +1648,30 @@ html.native-shell .app-layout { animation: settings-shell-enter 180ms ease-out; } +.repo-icon-emoji-picker .EmojiPickerReact { + --epr-bg-color: var(--card); + --epr-category-label-bg-color: var(--card); + --epr-text-color: var(--card-foreground); + --epr-search-input-bg-color: var(--input); + --epr-search-input-bg-color-active: var(--input); + --epr-search-border-color: var(--border); + --epr-search-border-color-active: var(--ring); + --epr-hover-bg-color: var(--accent); + --epr-highlight-color: var(--ring); + --epr-picker-border-color: var(--border); + border: none; +} + +/* The library sets `font-family: sans-serif` on every descendant, so inheriting from the root never lands. */ +.repo-icon-emoji-picker .EmojiPickerReact * { + font-family: var(--font-sans); +} + +.repo-icon-emoji-picker .epr-body { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--muted-foreground, #737373) 34%, transparent) transparent; +} + .collapsible-height-content { overflow: hidden; } diff --git a/src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx new file mode 100644 index 00000000000..da0292e631b --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx @@ -0,0 +1,129 @@ +// @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 type { AppState } from '@/store' +import { RepositoryIconEmojiPicker } from './RepositoryIconEmojiPicker' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { error: toastMocks.error } +})) + +// Mock store exposing only the theme value, to verify the minimal-selector subscription. +const storeMocks = vi.hoisted(() => ({ + state: { settings: { theme: 'light' } } as Partial +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Partial) => unknown) => selector(storeMocks.state) +})) + +vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({ + useSystemPrefersDark: () => false +})) + +/** Signature of the onEmojiClick callback captured by the emoji-picker-react mock. */ +type MockEmojiClickHandler = (data: { emoji: string }) => void + +const emojiPickerMocks = vi.hoisted(() => ({ + onEmojiClick: null as MockEmojiClickHandler | null, + searchPlaceholder: null as string | null +})) + +vi.mock('emoji-picker-react', () => ({ + __esModule: true, + default: (props: { onEmojiClick: MockEmojiClickHandler; searchPlaceholder: string }) => { + emojiPickerMocks.onEmojiClick = props.onEmojiClick + emojiPickerMocks.searchPlaceholder = props.searchPlaceholder + return null + }, + EmojiStyle: { NATIVE: 'native' }, + Theme: { DARK: 'dark', LIGHT: 'light' } +})) + +let container: HTMLDivElement +let root: Root + +/** Renders RepositoryIconEmojiPicker with default props and returns the spy callback. */ +function renderPicker(overrides: Partial[0]> = {}) { + const onSetIcon = vi.fn() + act(() => { + root.render() + }) + return { onSetIcon } +} + +describe('RepositoryIconEmojiPicker', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + emojiPickerMocks.onEmojiClick = null + emojiPickerMocks.searchPlaceholder = null + toastMocks.error.mockReset() + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + document.body.replaceChildren() + }) + + it('renders the full native emoji picker in place of the static grid', () => { + renderPicker() + expect(emojiPickerMocks.onEmojiClick).not.toBeNull() + expect(emojiPickerMocks.searchPlaceholder).toBe('Search emoji') + expect(container.querySelector('.repo-icon-emoji-picker')).not.toBeNull() + }) + + it('saves a valid emoji click through the existing RepoIcon contract', () => { + const { onSetIcon } = renderPicker() + act(() => { + emojiPickerMocks.onEmojiClick?.({ emoji: '🚀' }) + }) + expect(onSetIcon).toHaveBeenCalledExactlyOnceWith({ type: 'emoji', emoji: '🚀' }) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('rejects an emoji sequence over the sanitizeRepoIcon 16-char cap instead of silently dropping it', () => { + const { onSetIcon } = renderPicker() + // Two ZWJ family sequences back to back: valid codepoints, well over 16 UTF-16 units. + const overlong = '👨‍👩‍👧‍👦👨‍👩‍👧‍👦' + expect(overlong.length).toBeGreaterThan(16) + + act(() => { + emojiPickerMocks.onEmojiClick?.({ emoji: overlong }) + }) + + expect(onSetIcon).not.toHaveBeenCalled() + expect(toastMocks.error).toHaveBeenCalledTimes(1) + }) + + // Guards the skinTonesDisabled removal: skin-toned picks must save like any other emoji, not be blocked. + it('saves a valid skin-tone emoji selection instead of blocking it for global inclusivity', () => { + const { onSetIcon } = renderPicker() + // Base emoji + skin-tone modifier: well within the 16 UTF-16 unit cap. + const thumbsUpMediumSkinTone = '👍🏽' + expect(thumbsUpMediumSkinTone.length).toBeLessThanOrEqual(16) + + act(() => { + emojiPickerMocks.onEmojiClick?.({ emoji: thumbsUpMediumSkinTone }) + }) + + expect(onSetIcon).toHaveBeenCalledExactlyOnceWith({ + type: 'emoji', + emoji: thumbsUpMediumSkinTone + }) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('shows the currently selected emoji as trailing metadata', () => { + renderPicker({ selectedEmoji: '🎨' }) + expect(container.textContent).toContain('🎨') + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryIconEmojiPicker.tsx b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.tsx new file mode 100644 index 00000000000..9a97d07fbf6 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.tsx @@ -0,0 +1,76 @@ +import { toast } from 'sonner' +import EmojiPicker, { EmojiStyle, Theme, type EmojiClickData } from 'emoji-picker-react' +import type { RepoIcon } from '../../../../shared/repo-icon' +import { sanitizeRepoIcon } from '../../../../shared/repo-icon' +import { useAppStore } from '../../store' +import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' +import { translate } from '@/i18n/i18n' + +type RepositoryIconEmojiPickerProps = { + selectedEmoji: string + onSetIcon: (repoIcon: RepoIcon | null) => void +} + +/** + * Full native emoji picker for the repo icon "Emoji" tab. Kept in its own + * module (lazy-loaded by RepositoryIconTabs) so emoji-picker-react's ~500KB + * chunk only loads once this tab actually renders. + */ +export function RepositoryIconEmojiPicker({ + selectedEmoji, + onSetIcon +}: RepositoryIconEmojiPickerProps): React.JSX.Element { + // Minimal selector: only the theme string is needed; default to system before settings load. + const settingsTheme = useAppStore((state) => state.settings?.theme ?? 'system') + const systemPrefersDark = useSystemPrefersDark() + // Theme.AUTO only follows the OS setting, which can disagree with Orca's manual theme. + const isDarkTheme = settingsTheme === 'dark' || (settingsTheme === 'system' && systemPrefersDark) + + /** Saves the picked emoji, rejecting anything over sanitizeRepoIcon's 16-char cap. */ + const handleEmojiClick = (emojiData: EmojiClickData): void => { + // ZWJ/skin-tone sequences can exceed the 16-char cap even without skinTonesDisabled. + const repoIcon = sanitizeRepoIcon({ type: 'emoji', emoji: emojiData.emoji }) + if (!repoIcon) { + toast.error( + translate( + 'auto.components.settings.RepositoryIconPicker.emojiTooLongForRepoIcon', + "This emoji can't be used as a repo icon." + ) + ) + return + } + onSetIcon(repoIcon) + } + + return ( + <> +
+ +
+ {selectedEmoji ? ( +

+ {translate( + 'auto.components.settings.RepositoryIconPicker.currentEmojiSelection', + 'Current: {{value0}}', + { value0: selectedEmoji } + )} +

+ ) : null} + + ) +} diff --git a/src/renderer/src/components/settings/RepositoryIconTabs.tsx b/src/renderer/src/components/settings/RepositoryIconTabs.tsx index d0507a89db4..1d695da6085 100644 --- a/src/renderer/src/components/settings/RepositoryIconTabs.tsx +++ b/src/renderer/src/components/settings/RepositoryIconTabs.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { Suspense, useState } from 'react' import { toast } from 'sonner' import { Github, Image, Link2 } from 'lucide-react' import type { RepoIcon } from '../../../../shared/repo-icon' @@ -10,8 +10,16 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { getRepoLucideIconOptions } from '../repo/repo-icon' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' +import { lazyWithRetry } from '@/lib/lazy-with-retry' -const EMOJI_OPTIONS = ['🚀', '✨', '💻', '🧠', '📦', '🔧', '🎨', '🌐', '📊', '🔒', '⚡', '✅'] +// Deferred so emoji-picker-react's ~500KB chunk only loads once the Emoji tab renders. +const RepositoryIconEmojiPicker = lazyWithRetry( + () => + import('./RepositoryIconEmojiPicker').then((module) => ({ + default: module.RepositoryIconEmojiPicker + })), + { reloadKey: 'repo-icon-emoji-picker' } +) type RepositoryIconTabsProps = { initialTab: 'avatar' | 'icon' | 'emoji' @@ -180,24 +188,10 @@ export function RepositoryIconTabs({ - - {EMOJI_OPTIONS.map((emoji) => ( - - ))} + + + + ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7b55bf13367..05082ae9ba8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6644,7 +6644,10 @@ "f79972271a": "No GitHub remote found for this repo.", "4d039317f4": "Website favicon", "acf31559a0": "Enter a valid website URL.", - "868c5c9b56": "Failed to import repo icon" + "868c5c9b56": "Failed to import repo icon", + "emojiTooLongForRepoIcon": "This emoji can't be used as a repo icon.", + "currentEmojiSelection": "Current: {{value0}}", + "searchEmojiPlaceholder": "Search emoji" }, "RepositoryPane": { "15a99d9b9f": "Relative paths resolve from this project root.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 904beaa9d16..14dd169539e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6483,7 +6483,10 @@ "f79972271a": "No se encontró ningún control remoto de GitHub para este repositorio.", "4d039317f4": "Favicon del sitio web", "acf31559a0": "Ingresa una URL de sitio web válida.", - "868c5c9b56": "No se pudo importar el icono del repositorio" + "868c5c9b56": "No se pudo importar el icono del repositorio", + "emojiTooLongForRepoIcon": "Este emoji no se puede usar como icono del repositorio.", + "currentEmojiSelection": "Actual: {{value0}}", + "searchEmojiPlaceholder": "Buscar emoji" }, "RepositoryPane": { "15a99d9b9f": "Las rutas relativas se resuelven desde la raíz de este proyecto.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3d5f8588d72..9ec2f14a80d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6505,7 +6505,10 @@ "f79972271a": "この repo の GitHub リモートが見つかりません。", "4d039317f4": "ウェブサイトのファビコン", "acf31559a0": "有効な Web サイトの URL を入力。", - "868c5c9b56": "repo のインポートに失敗しましたアイコン" + "868c5c9b56": "repo のインポートに失敗しましたアイコン", + "emojiTooLongForRepoIcon": "この絵文字は repo アイコンとして使用できません。", + "currentEmojiSelection": "現在: {{value0}}", + "searchEmojiPlaceholder": "絵文字を検索" }, "RepositoryPane": { "15a99d9b9f": "相対パスは、このプロジェクトのルートから解決されます。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index aa4bc49ce93..152e6a771dc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6468,7 +6468,10 @@ "f79972271a": "이 repo에 대한 GitHub 원격을 찾을 수 없습니다.", "4d039317f4": "웹사이트 파비콘", "acf31559a0": "유효한 웹사이트 URL을 입력하세요.", - "868c5c9b56": "repo 아이콘을 가져오지 못했습니다." + "868c5c9b56": "repo 아이콘을 가져오지 못했습니다.", + "emojiTooLongForRepoIcon": "이 이모티콘은 repo 아이콘으로 사용할 수 없습니다.", + "currentEmojiSelection": "현재: {{value0}}", + "searchEmojiPlaceholder": "이모티콘 검색" }, "RepositoryPane": { "15a99d9b9f": "상대 경로는 이 프로젝트 루트에서 확인됩니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 05a13abd266..7f9907ab181 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6480,7 +6480,10 @@ "f79972271a": "找不到此仓库的 GitHub 远程。", "4d039317f4": "网站图标", "acf31559a0": "输入有效的网站 URL。", - "868c5c9b56": "无法导入仓库图标" + "868c5c9b56": "无法导入仓库图标", + "emojiTooLongForRepoIcon": "此表情符号无法用作仓库图标。", + "currentEmojiSelection": "当前:{{value0}}", + "searchEmojiPlaceholder": "搜索表情符号" }, "RepositoryPane": { "15a99d9b9f": "相对路径从该项目根解析。", diff --git a/tests/e2e/repo-icon-emoji-picker.spec.ts b/tests/e2e/repo-icon-emoji-picker.spec.ts new file mode 100644 index 00000000000..2698615b881 --- /dev/null +++ b/tests/e2e/repo-icon-emoji-picker.spec.ts @@ -0,0 +1,80 @@ +/** + * Coverage for the full native emoji picker that replaced the hardcoded + * 12-emoji grid in the repo icon settings (RepositoryIconTabs "Emoji" tab). + * Verifies search + selection persist through the existing RepoIcon + * contract, and captures the new picker for the PR screenshot record. + */ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { getStoreState, waitForSessionReady } from './helpers/store' +import type { Repo } from '../../src/shared/types' + +/** Opens the repo settings panel and pins the UI language to English. */ +async function openRepoSettings(page: Page, repoId: string): Promise { + // Why: the host OS locale (e.g. ko-KR) drives Orca's default UI language. + // Pin English so this spec's locators are stable across dev machines and CI. + await page.evaluate(() => window.__store!.getState().updateSettings({ uiLanguage: 'en' })) + await page.evaluate((repoId) => { + const state = window.__store!.getState() + state.openSettingsTarget({ pane: 'repo', repoId }) + state.openSettingsPage() + }, repoId) + await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 }) + const maybeLaterButton = page.getByRole('button', { name: 'Maybe Later' }) + if (await maybeLaterButton.isVisible({ timeout: 1_000 }).catch(() => false)) { + await maybeLaterButton.click() + } +} + +/** Captures a full-page screenshot and attaches it to the test report. */ +async function attachScreenshot(page: Page, testInfo: TestInfo, name: string): Promise { + const screenshotPath = testInfo.outputPath(`${name}.png`) + await page.screenshot({ path: screenshotPath }) + await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }) +} + +test.describe('Repository icon emoji picker', () => { + test('search selects a native emoji and persists it as the repo icon', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + + const repos = await getStoreState(orcaPage, 'repos') + expect(repos.length).toBeGreaterThan(0) + const repo = repos[0] + + await openRepoSettings(orcaPage, repo.id) + + const repoSection = orcaPage.locator(`[data-settings-section="repo-${repo.id}"]`) + await repoSection.getByRole('tab', { name: 'Emoji' }).click() + + const picker = repoSection.locator('.repo-icon-emoji-picker') + await expect(picker).toBeVisible({ timeout: 10_000 }) + + // Full catalog with search — the removed grid only ever offered 12 fixed emoji. + const searchInput = picker.getByPlaceholder('Search emoji') + await expect(searchInput).toBeVisible() + await searchInput.fill('rocket') + + await attachScreenshot(orcaPage, testInfo, 'repo-icon-emoji-picker-search') + + const rocketResult = picker.getByRole('button', { name: /rocket/i }).first() + await expect(rocketResult).toBeVisible({ timeout: 10_000 }) + await rocketResult.click() + + await expect + .poll( + async () => { + const current = await getStoreState(orcaPage, 'repos') + return current.find((entry) => entry.id === repo.id)?.repoIcon + }, + { timeout: 5_000, message: 'repo icon did not persist the picked emoji' } + ) + .toEqual({ type: 'emoji', emoji: '🚀' }) + + // The store round-trip alone would pass even if the panel rendered nothing. + await expect(repoSection.getByText('Current: 🚀')).toBeVisible() + + await attachScreenshot(orcaPage, testInfo, 'repo-icon-emoji-picker-selected') + }) +}) From dbbeca091644b0e577eb19e4e8c3363604d5bb4e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:05:22 -0700 Subject: [PATCH 036/101] Let a timed-out remote terminal pane reconnect when the host session is still alive (#12213) * fix(terminal): let a timed-out remote terminal pane reconnect again A remote-runtime PTY pane that missed the 60s auto-recovery window latched to "disconnected" permanently, even while the host kept the session alive and streaming. The cutoff destroyed every path back at once: it dropped the pending retry and evicted the pane from the shared retry registry (so window `online` and system-resume became no-ops), and the transport tore down the accepted host-snapshot listener. The listener was also inert for the common case, since a host that keeps publishing the same live handle never rotates it. The cutoff now only stops the retry timer; the pane keeps its pending retry and stays revivable. The accepted-snapshot listener survives the latch, and a post-cutoff snapshot is accepted as reattach evidence whether the host rotated the handle or republished the same one. The require-replacement published-wait guard is scoped to its own recovery epoch so Reconnect and online/resume are no longer swallowed. No new polling: a latched pane still issues zero self-initiated RPCs, and the same-handle reattach consumes a snapshot the client already receives. Closes #12097 * fix(terminal): drop the settled attach retry when the recovery cutoff lands The recovery cutoff now keeps a pending retry so online/resume can revive a latched pane, but the host-session attach wait schedules a single-shot closure that the cutoff itself resolves. Retaining it left the pane registered as revivable work: 'online' would bump the epoch, arm a fresh 60s deadline and flip the phase to 'recovering' while invoking a no-op, hiding the working Reconnect button and the same-handle snapshot reattach for a full minute. The attach wait now discards its own scheduled retry as it settles, so a pane latched on that path stays 'disconnected' and Reconnect keeps working. Retries scheduled by the resubscribe paths re-enter real work and are unaffected. Co-authored-by: Orca * fix(test): type the reattach host snapshot factory so status narrows to 'ready' Co-authored-by: Orca * fix(terminal): keep an exhausted remote pane reattachable through Reconnect and resume Reconnect and the online/system-resume trigger both opened a fresh recovery epoch, which switched off the accepted-snapshot reattach path (gated on the 'disconnected' phase) for 60s while the require-replacement inventory wait dead-ended without scheduling anything. Gate the same-handle reattach on a spent auto-recovery window instead of the live phase, consumed once per window, and park an unarmed retry at the require-replacement dead end so online/resume/Reconnect have work to revive. Co-authored-by: Orca * test(terminal): pin latched-pane retention to one listener and one registry entry The recovery cutoff now keeps the retry-registry entry and the accepted-snapshot listener alive, so cover the two module-global collections that could accumulate: destroy/detach cycles, concurrent latched panes, revive storms and snapshot churn all return to baseline. Co-authored-by: Orca * refactor(terminal): use the returned reattach epoch and pin the no-RPC claim Addresses both CodeRabbit nitpicks: subscribeToHandle now takes the epoch begin() returned rather than re-reading currentEpoch, and the latched attach-wait test asserts the runtime call count is unchanged instead of only the advanced count. Co-authored-by: Orca --------- Co-authored-by: Orca --- ...mote-runtime-pty-deadline-reattach.test.ts | 381 ++++++++++++++++++ ...runtime-pty-latched-pane-retention.test.ts | 358 ++++++++++++++++ .../remote-runtime-pty-recovery-state.test.ts | 51 ++- .../remote-runtime-pty-recovery-state.ts | 46 ++- .../remote-runtime-pty-transport.ts | 58 ++- 5 files changed, 880 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts new file mode 100644 index 00000000000..886516414ca --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts @@ -0,0 +1,381 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../shared/terminal-stream-protocol' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' + +describe('remote runtime pty reattach after the bounded recovery window', () => { + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + const refreshSessionTabsSnapshot = vi.fn(async () => {}) + const subscriptionSendBinary = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + let hostListCalls = 0 + + function emitMultiplexReady(): void { + subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'ready' } }) + } + + function latestSubscribePayload(): { streamId: number; terminal: string } { + const frame = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .findLast((candidate) => candidate?.opcode === TerminalStreamOpcode.Subscribe) + if (!frame) { + throw new Error('missing terminal subscribe frame') + } + const payload = decodeTerminalStreamJson<{ streamId: number; terminal: string }>(frame.payload) + if (!payload) { + throw new Error('invalid terminal subscribe payload') + } + return payload + } + + function subscribedTerminalHandles(): string[] { + return subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .flatMap((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.Subscribe) { + return [] + } + const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload) + return payload ? [payload.terminal] : [] + }) + } + + function emitSnapshot(streamId: number, data: string): void { + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 1, + payload: encodeTerminalStreamJson({ kind: 'scrollback' }) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(data) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 3, + payload: new Uint8Array() + }) + ) + } + + function hostSnapshot( + terminal: string, + snapshotVersion: number, + publicationEpoch: string + ): RuntimeMobileSessionTabsResult { + return { + worktree: 'wt-1', + publicationEpoch, + snapshotVersion, + activeGroupId: null, + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal' as const, + tabs: [ + { + type: 'terminal' as const, + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Claude Code', + isActive: true, + status: 'ready', + terminal + } + ] + } + } + + async function attachStalePane() { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const onPtyExit = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-tab-1', + leafId: 'pane:1', + onPtyExit, + onPtyRebind: vi.fn() + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-stale', + cols: 80, + rows: 24, + callbacks: { onError } + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + + // The host keeps publishing the same live handle, so bounded replacement polling finds + // no replacement and stops quietly without retiring the pane. + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method !== 'session.tabs.list') { + return { ok: true, result: {} } + } + hostListCalls += 1 + return { ok: true, result: hostSnapshot('terminal-stale', hostListCalls + 1, 'epoch-1') } + }) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'error', + streamId: latestSubscribePayload().streamId, + message: 'terminal_handle_stale' + } + }) + return { transport, onError, onPtyExit } + } + + beforeEach(() => { + vi.resetModules() + vi.doUnmock('../../runtime/remote-runtime-terminal-multiplexer') + vi.doMock('@/runtime/web-runtime-session', () => ({ + refreshWebRuntimeSessionTabsSnapshot: refreshSessionTabsSnapshot + })) + vi.clearAllMocks() + subscriptionCallbacks = null + hostListCalls = 0 + subscriptionSendBinary.mockReset() + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate') { + return { ok: true, result: hostSnapshot('terminal-stale', 1, 'epoch-1') } + } + if (request.method === 'terminal.resolvePane') { + const params = request.params as { paneKey: string; worktreeId: string } + const separator = params.paneKey.indexOf(':') + return { + ok: true, + result: { + terminal: { + handle: 'terminal-stale', + tabId: params.paneKey.slice(0, separator), + leafId: params.paneKey.slice(separator + 1), + worktreeId: params.worktreeId + } + } + } + } + return { ok: true, result: { terminal: { handle: 'terminal-stale' } } } + }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + queueMicrotask(emitMultiplexReady) + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } } + }) + }) + + it('reattaches from a rotated host handle published after the recovery cutoff', async () => { + vi.useFakeTimers() + try { + const { transport, onPtyExit } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(16_000) + expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) + expect(transport.getRecoveryState?.().phase).not.toBe('disconnected') + + await vi.advanceTimersByTimeAsync(50_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + // The cutoff must not tear down the accepted-snapshot listener; it is the only path back. + expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-after-timeout', 3, 'epoch-2'), + 'env-1' + ) + await vi.waitFor(() => + expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-after-timeout' }) + ) + + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-after-timeout') + expect(onPtyExit).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('reattaches when the post-cutoff snapshot republishes the same live handle', async () => { + vi.useFakeTimers() + try { + const { transport, onError } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const listCallsAtCutoff = hostListCalls + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 3, 'epoch-2'), + 'env-1' + ) + await vi.waitFor(() => expect(subscribedTerminalHandles()).toHaveLength(2)) + + expect(subscribedTerminalHandles()).toEqual(['terminal-stale', 'terminal-stale']) + // The snapshot is already-received host evidence; reattaching must cost no inventory RPC. + expect(hostListCalls).toBe(listCallsAtCutoff) + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('ignores a same-handle snapshot while automatic recovery is still running', async () => { + vi.useFakeTimers() + try { + const { transport } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(16_000) + expect(transport.getRecoveryState?.().phase).not.toBe('disconnected') + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 3, 'epoch-2'), + 'env-1' + ) + await vi.advanceTimersByTimeAsync(1_000) + + expect(subscribedTerminalHandles()).toEqual(['terminal-stale']) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('reattaches from a same-handle snapshot delivered inside the Reconnect window', async () => { + vi.useFakeTimers() + try { + const { transport, onError } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) + const listCallsAtCutoff = hostListCalls + + expect(transport.retryRecovery?.()).toBe(true) + await vi.advanceTimersByTimeAsync(1_000) + expect(hostListCalls).toBeGreaterThan(listCallsAtCutoff) + + // Reconnect must not disarm the only path back: the click opens a window, it does not spend one. + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 4, 'epoch-3'), + 'env-1' + ) + await vi.waitFor(() => expect(subscribedTerminalHandles()).toHaveLength(2)) + + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('revives a latched require-replacement pane when online or system resume fires', async () => { + vi.useFakeTimers() + try { + const { transport, onError } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + const { retryAllRemoteRuntimePtyRecoveriesNow } = + await import('./remote-runtime-pty-recovery-state') + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const listCallsAtCutoff = hostListCalls + + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(1) + await vi.advanceTimersByTimeAsync(1_000) + expect(hostListCalls).toBeGreaterThan(listCallsAtCutoff) + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 4, 'epoch-3'), + 'env-1' + ) + await vi.waitFor(() => expect(subscribedTerminalHandles()).toHaveLength(2)) + + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('leaves Reconnect available when online fires on a pane latched during the attach wait', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { retryAllRemoteRuntimePtyRecoveriesNow } = + await import('./remote-runtime-pty-recovery-state') + runtimeCall.mockImplementation(async (request: { method: string }) => { + if (request.method === 'session.tabs.activate') { + return { + ok: false, + error: { + code: 'remote_runtime_unavailable', + message: 'Remote Orca runtime connection closed' + } + } + } + return { ok: true, result: {} } + }) + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-tab-1', + leafId: 'pane:1', + onPtyExit: vi.fn(), + onPtyRebind: vi.fn() + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-stale', + cols: 80, + rows: 24, + callbacks: { onError: vi.fn() } + }) + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + const callsBeforeRetry = runtimeCall.mock.calls.length + // The attach wait is single-shot and the cutoff already settled it; replaying it would spin the banner with no RPC in flight. + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(0) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + expect(runtimeCall.mock.calls.length).toBe(callsBeforeRetry) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts new file mode 100644 index 00000000000..e9c83bf6718 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts @@ -0,0 +1,358 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson +} from '../../../../shared/terminal-stream-protocol' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' + +// Why: the recovery cutoff no longer tears down the retry registry entry or the accepted-snapshot +// listener, so those two module-global collections are the only places a latched pane can accumulate. +describe('remote runtime pty latched-pane retention', () => { + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + const refreshSessionTabsSnapshot = vi.fn(async () => {}) + const subscriptionSendBinary = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + let hostListCalls = 0 + + function emitMultiplexReady(): void { + subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'ready' } }) + } + + function latestSubscribePayload(): { streamId: number; terminal: string } { + const frame = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .findLast((candidate) => candidate?.opcode === TerminalStreamOpcode.Subscribe) + if (!frame) { + throw new Error('missing terminal subscribe frame') + } + const payload = decodeTerminalStreamJson<{ streamId: number; terminal: string }>(frame.payload) + if (!payload) { + throw new Error('invalid terminal subscribe payload') + } + return payload + } + + function subscribeFrameCount(): number { + return subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe).length + } + + // Why: every pane needs its own host surface, otherwise a later pane reuses the earlier pane's + // multiplexed stream and never enters recovery at all. + const paneIdentity = (pane: number) => ({ + hostTabId: `tab-${pane}`, + tabId: `web-terminal-tab-${pane}`, + leafId: `pane:${pane}`, + handle: `terminal-stale-${pane}` + }) + + const PANE_COUNT = 24 + + // Why: one payload carries every pane's surface, matching the real per-worktree session.tabs + // response that any polling pane receives. + function hostSnapshot( + snapshotVersion: number, + publicationEpoch: string + ): RuntimeMobileSessionTabsResult { + return { + worktree: 'wt-1', + publicationEpoch, + snapshotVersion, + activeGroupId: null, + activeTabId: `${paneIdentity(0).hostTabId}::${paneIdentity(0).leafId}`, + activeTabType: 'terminal' as const, + tabs: Array.from({ length: PANE_COUNT }, (_unused, pane) => { + const identity = paneIdentity(pane) + return { + type: 'terminal' as const, + id: `${identity.hostTabId}::${identity.leafId}`, + parentTabId: identity.hostTabId, + leafId: identity.leafId, + title: 'Claude Code', + isActive: pane === 0, + status: 'ready' as const, + terminal: identity.handle + } + }) + } + } + + async function attachStalePane(pane: number) { + const identity = paneIdentity(pane) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: identity.tabId, + leafId: identity.leafId, + onPtyExit: vi.fn(), + onPtyRebind: vi.fn() + }) + const subscribesBefore = subscribeFrameCount() + transport.attach({ + existingPtyId: `remote:env-1@@${identity.handle}`, + cols: 80, + rows: 24, + callbacks: { onError: vi.fn() } + }) + await vi.waitFor(() => expect(subscribeFrameCount()).toBeGreaterThan(subscribesBefore)) + + // The host keeps publishing the same live handle, so bounded replacement polling finds no + // replacement and stops quietly: the exact shape that leaves the pane latched but revivable. + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'error', + streamId: latestSubscribePayload().streamId, + message: 'terminal_handle_stale' + } + }) + return transport + } + + async function registries() { + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + const recoveryState = await import('./remote-runtime-pty-recovery-state') + return { + subscribers: handleEvents.getWebSessionTerminalHandleSubscriberCountForTests(), + scheduled: recoveryState.getScheduledRemoteRuntimePtyRecoveryCountForTests() + } + } + + beforeEach(() => { + vi.resetModules() + vi.doUnmock('../../runtime/remote-runtime-terminal-multiplexer') + vi.doMock('@/runtime/web-runtime-session', () => ({ + refreshWebRuntimeSessionTabsSnapshot: refreshSessionTabsSnapshot + })) + vi.clearAllMocks() + subscriptionCallbacks = null + hostListCalls = 0 + subscriptionSendBinary.mockReset() + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.list') { + hostListCalls += 1 + return { ok: true, result: hostSnapshot(hostListCalls + 1, 'epoch-1') } + } + if (request.method === 'session.tabs.activate') { + return { ok: true, result: hostSnapshot(1, 'epoch-1') } + } + if (request.method === 'terminal.resolvePane') { + const params = request.params as { paneKey: string; worktreeId: string } + const separator = params.paneKey.indexOf(':') + const paneTabId = params.paneKey.slice(0, separator) + const pane = Number(paneTabId.slice(paneTabId.lastIndexOf('-') + 1)) + return { + ok: true, + result: { + terminal: { + handle: paneIdentity(pane).handle, + tabId: paneTabId, + leafId: params.paneKey.slice(separator + 1), + worktreeId: params.worktreeId + } + } + } + } + return { ok: true, result: {} } + }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + queueMicrotask(emitMultiplexReady) + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } } + }) + }) + + it('returns listener and retry-registry counts to baseline across destroy cycles', async () => { + vi.useFakeTimers() + try { + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + const latched: { subscribers: number; scheduled: number }[] = [] + const settled: { subscribers: number; scheduled: number }[] = [] + + for (let cycle = 0; cycle < 20; cycle += 1) { + const transport = await attachStalePane(cycle) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + latched.push(await registries()) + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + settled.push(await registries()) + } + + // A latched pane deliberately holds exactly one listener and one registry entry... + expect(latched).toEqual(Array.from({ length: 20 }, () => ({ subscribers: 1, scheduled: 1 }))) + // ...and destroy releases both, so twenty cycles do not accumulate anything. + expect(settled).toEqual(Array.from({ length: 20 }, () => ({ subscribers: 0, scheduled: 0 }))) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('returns to baseline when a latched pane is detached rather than destroyed', async () => { + vi.useFakeTimers() + try { + const settled: { subscribers: number; scheduled: number }[] = [] + for (let cycle = 0; cycle < 20; cycle += 1) { + const transport = await attachStalePane(cycle) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + transport.detach?.() + await vi.advanceTimersByTimeAsync(1_000) + settled.push(await registries()) + } + expect(settled).toEqual(Array.from({ length: 20 }, () => ({ subscribers: 0, scheduled: 0 }))) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('holds one listener and one registry entry per concurrently latched pane', async () => { + vi.useFakeTimers() + try { + const transports: Awaited>[] = [] + for (let pane = 0; pane < 8; pane += 1) { + transports.push(await attachStalePane(pane)) + await vi.advanceTimersByTimeAsync(66_000) + } + // Retention is per live pane, not per timeout: eight latched panes hold eight of each. + expect(await registries()).toEqual({ subscribers: 8, scheduled: 8 }) + + for (const transport of transports) { + transport.destroy?.() + } + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('leaves a latched pane fully quiescent — no timers, no RPCs, no growth', async () => { + vi.useFakeTimers() + try { + const transport = await attachStalePane(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + const baseline = await registries() + const callsAtLatch = runtimeCall.mock.calls.length + const timersAtLatch = vi.getTimerCount() + // Nothing is armed once the window is spent: the pane costs one listener and one registry + // entry, and nothing else, no matter how long it stays latched. + expect(timersAtLatch).toBe(0) + + await vi.advanceTimersByTimeAsync(10 * 60_000) + + expect(runtimeCall.mock.calls.length).toBe(callsAtLatch) + expect(vi.getTimerCount()).toBe(0) + expect(await registries()).toEqual(baseline) + + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it('does not stack listeners, registry entries or timers across repeated revive cycles', async () => { + vi.useFakeTimers() + try { + const { retryAllRemoteRuntimePtyRecoveriesNow } = await import( + './remote-runtime-pty-recovery-state' + ) + const transport = await attachStalePane(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + const baseline = await registries() + const timersAtFirstLatch = vi.getTimerCount() + const subscribesAtFirstLatch = subscribeFrameCount() + const observed: { + subscribers: number + scheduled: number + timers: number + revived: number + }[] = [] + + for (let cycle = 0; cycle < 25; cycle += 1) { + const revived = retryAllRemoteRuntimePtyRecoveriesNow() + // A second trigger in the same window must find nothing to advance, so an online/resume + // storm cannot stack fresh recovery epochs on one pane. + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + observed.push({ ...(await registries()), timers: vi.getTimerCount(), revived }) + } + + expect(observed).toEqual( + Array.from({ length: 25 }, () => ({ + subscribers: baseline.subscribers, + scheduled: baseline.scheduled, + timers: timersAtFirstLatch, + revived: 1 + })) + ) + // Each revive re-derives the handle; it must not leave extra live stream subscriptions behind. + expect(subscribeFrameCount()).toBe(subscribesAtFirstLatch) + + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not stack anything when host snapshots arrive repeatedly at a latched pane', async () => { + vi.useFakeTimers() + try { + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + const transport = await attachStalePane(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const baseline = await registries() + + const subscribesAtLatch = subscribeFrameCount() + + for (let cycle = 0; cycle < 50; cycle += 1) { + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot(10 + cycle, `epoch-${cycle}`), + 'env-1' + ) + await vi.advanceTimersByTimeAsync(100) + } + + const after = await registries() + expect(after.subscribers).toBeLessThanOrEqual(baseline.subscribers) + expect(after.scheduled).toBeLessThanOrEqual(baseline.scheduled) + // At most one same-handle resubscribe per spent window: 50 snapshots inside one window + // must not become 50 subscribes. + expect(subscribeFrameCount() - subscribesAtLatch).toBeLessThanOrEqual(1) + + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts index f2556b737b0..07a9199d307 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts @@ -69,6 +69,7 @@ describe('RemoteRuntimePtyRecoveryState', () => { expect(state.isActive).toBe(false) expect(state.isCurrent(epoch)).toBe(false) expect(onChange).toHaveBeenCalled() + state.dispose() }) it('advances a pending backoff immediately via retryNow and the active registry', async () => { @@ -154,16 +155,56 @@ describe('RemoteRuntimePtyRecoveryState', () => { expect(retryNow).not.toHaveBeenCalled() }) - it('removes timed-out panes from the scheduled recovery registry', async () => { + it('keeps a timed-out pane revivable through the scheduled recovery registry', async () => { + vi.useFakeTimers() + const state = new RemoteRuntimePtyRecoveryState() + const retry = vi.fn() + const firstEpoch = state.begin() + // Why: the backoff ladder outlasts the recovery window, so a retry is still armed when the cutoff lands. + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS - 100) + state.schedule(firstEpoch, retry) + + await vi.advanceTimersByTimeAsync(100) + expect(state.currentPhase).toBe('disconnected') + + await vi.advanceTimersByTimeAsync(300_000) + expect(retry).not.toHaveBeenCalled() + + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(1) + expect(retry).toHaveBeenCalledWith(firstEpoch + 1) + expect(state.currentPhase).toBe('recovering') + state.dispose() + }) + + it('revives a parked retry that never armed a backoff timer', async () => { + vi.useFakeTimers() + const state = new RemoteRuntimePtyRecoveryState() + const retry = vi.fn() + const firstEpoch = state.begin() + expect(state.parkRetryForExternalTrigger(firstEpoch, retry)).toBe(true) + + // Why: parking must not fire on its own, so the pane still latches at the cutoff. + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 300_000) + expect(state.currentPhase).toBe('disconnected') + expect(retry).not.toHaveBeenCalled() + + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(1) + expect(retry).toHaveBeenCalledWith(firstEpoch + 1) + expect(state.currentPhase).toBe('recovering') + state.dispose() + }) + + it('refuses to park over an armed backoff or a stale epoch', () => { vi.useFakeTimers() const state = new RemoteRuntimePtyRecoveryState() const epoch = state.begin() state.schedule(epoch, vi.fn()) - const retryNow = vi.spyOn(state, 'retryNow') - await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) - retryAllRemoteRuntimePtyRecoveriesNow() + expect(state.parkRetryForExternalTrigger(epoch, vi.fn())).toBe(false) - expect(retryNow).not.toHaveBeenCalled() + state.cancel() + expect(state.parkRetryForExternalTrigger(epoch, vi.fn())).toBe(false) + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(0) + state.dispose() }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts index df6814a4028..0b609ff876c 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts @@ -11,6 +11,10 @@ export type RemoteRuntimePtyRecoveryPhase = // Why: system resume / network online need to advance pending pane backoffs without a second coordinator. const scheduledRecoveries = new Set() +export function getScheduledRemoteRuntimePtyRecoveryCountForTests(): number { + return scheduledRecoveries.size +} + export function retryAllRemoteRuntimePtyRecoveriesNow(): number { let advanced = 0 // Why: a synchronous retry failure can schedule the same state again. @@ -101,14 +105,43 @@ export class RemoteRuntimePtyRecoveryState { return true } + // Why: a wait that ends with no liveness evidence arms no timer, so park a retry or online/resume/reconnect find nothing to revive. + parkRetryForExternalTrigger(epoch: number, retry: (epoch: number) => void): boolean { + if (!this.isCurrent(epoch) || this.pendingRetry !== null) { + return false + } + this.pendingRetry = retry + this.pendingEpoch = epoch + scheduledRecoveries.add(this) + return true + } + + // Why: a one-shot retry whose owner already resolved elsewhere would otherwise survive the cutoff as fake revivable work. + discardPendingRetry(retry: (epoch: number) => void): void { + if (this.pendingRetry !== retry) { + return + } + this.clearRetryTimer() + } + // Why: resume/online should fire an already-scheduled backoff immediately, not start a new epoch. retryNow(): boolean { - if (this.phase !== 'backoff' || this.pendingRetry === null || this.pendingEpoch === null) { + if (this.pendingRetry === null || this.pendingEpoch === null) { + return false + } + if (this.phase !== 'backoff' && this.phase !== 'disconnected') { return false } const retry = this.pendingRetry - const epoch = this.pendingEpoch + const latched = this.phase === 'disconnected' this.clearRetryTimer() + if (latched) { + // Why: the deadline only stops auto-retry; an explicit trigger opens a fresh recovery window. + this.epoch += 1 + this.attempt = 0 + this.armDeadline(this.epoch) + } + const epoch = this.epoch this.phase = 'recovering' this.onChange?.() retry(epoch) @@ -159,7 +192,8 @@ export class RemoteRuntimePtyRecoveryState { return } this.deadlineTimer = null - this.clearRetryTimer() + // Why: the cutoff stops self-initiated retries but must keep the pane revivable by online/resume/reconnect. + this.stopRetryTimer() this.phase = 'disconnected' this.onChange?.() }, REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) @@ -172,11 +206,15 @@ export class RemoteRuntimePtyRecoveryState { this.clearDeadlineTimer() } - private clearRetryTimer(): void { + private stopRetryTimer(): void { if (this.retryTimer) { clearTimeout(this.retryTimer) this.retryTimer = null } + } + + private clearRetryTimer(): void { + this.stopRetryTimer() this.pendingRetry = null this.pendingEpoch = null scheduledRecoveries.delete(this) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index eedd195c71e..c9e0ea4352f 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -187,6 +187,9 @@ export function createRemoteRuntimePtyTransport( let recoveryReplacementPolicy: HostHandleReplacementPolicy = 'reuse' let recoveryReplacementPolicyHandle: string | null = null let stopWaitingForPublishedHandle: (() => void) | null = null + let publishedHandleWaitEpoch: number | null = null + // Why: a spent auto-recovery window is the evidence that licenses reattaching the fenced handle; explicit retries must not erase it. + let autoRecoveryWindowSpent = false let settleHostSessionAttachRetry: ((retry: boolean) => void) | null = null let resubscribeInventoryEpoch: number | null = null let resubscribeInventoryWindows = 0 @@ -236,12 +239,18 @@ export function createRemoteRuntimePtyTransport( } const recovery = new RemoteRuntimePtyRecoveryState(() => { - if (recovery.currentPhase === 'disconnected') { + if (recovery.currentPhase === 'disposed') { clearPublishedHandleWait() + } + if (recovery.currentPhase === 'disconnected') { + autoRecoveryWindowSpent = true // Why: cached pixels may remain, but no stream from the exhausted epoch may keep delivering or accepting terminal traffic. subscriptionGeneration += 1 closeMultiplexedStream() } + if (recovery.currentPhase === 'idle') { + autoRecoveryWindowSpent = false + } if ( recovery.currentPhase === 'disconnected' || recovery.currentPhase === 'disposed' || @@ -562,11 +571,16 @@ export function createRemoteRuntimePtyTransport( if (settleHostSessionAttachRetry === settle) { settleHostSessionAttachRetry = null } + // Why: this wait is single-shot, so replaying it after the cutoff would strand the pane in 'recovering' with no RPC in flight. + recovery.discardPendingRetry(scheduledRetry) resolve(retry) } + const scheduledRetry = (): void => { + settle(true) + } settleHostSessionAttachRetry?.(false) settleHostSessionAttachRetry = settle - if (!recovery.schedule(recoveryEpoch, () => settle(true))) { + if (!recovery.schedule(recoveryEpoch, scheduledRetry)) { settle(false) } }) @@ -1257,6 +1271,7 @@ export function createRemoteRuntimePtyTransport( function clearPublishedHandleWait(): void { stopWaitingForPublishedHandle?.() stopWaitingForPublishedHandle = null + publishedHandleWaitEpoch = null } function isCurrentRemoteTerminal(targetHandle: string, targetPtyId: string | null): boolean { @@ -1329,9 +1344,30 @@ export function createRemoteRuntimePtyTransport( retireRemoteTerminalId() return } - if (!update.terminalHandle || update.terminalHandle === previousHandle) { + if (!update.terminalHandle) { return } + if (update.terminalHandle === previousHandle) { + // Why: once the auto-recovery window is spent, a host still publishing this surface is evidence the fenced handle outlived the stale error. + if (!autoRecoveryWindowSpent || getCurrentMultiplexedStream(previousHandle)) { + return + } + // Why: one reattach per spent window, so a handle that really is dead is not retried on every host snapshot. + autoRecoveryWindowSpent = false + const reattachEpoch = recovery.begin() + clearPublishedHandleWait() + const reusedPtyId = remotePtyId + void subscribeToHandle(reattachEpoch, true).catch((error) => { + if (!recoverAfterSubscribeFailure(error, previousHandle, reusedPtyId)) { + handleRemoteTerminalError(error) + } + }) + return + } + if (recovery.currentPhase === 'disconnected') { + // Why: without a live epoch a failed resubscribe is swallowed as already-latched, leaving a pane with no handle and no way back. + recovery.begin() + } rebindRemoteTerminalHandle(update.terminalHandle) const reboundHandle = handle const reboundPtyId = remotePtyId @@ -1440,6 +1476,13 @@ export function createRemoteRuntimePtyTransport( code: 'remote_runtime_unavailable' }) } + // Why: liveness is unknown, so auto-retry stops here; keep an unarmed retry parked for online/resume/reconnect to fire. + recovery.parkRetryForExternalTrigger(recoveryEpoch, (nextEpoch) => { + scheduleResubscribeAfterTransportClose( + handle ? getRecoveryReplacementPolicy(handle) : 'reuse', + nextEpoch + ) + }) return } if (!nextHandle) { @@ -1507,8 +1550,12 @@ export function createRemoteRuntimePtyTransport( clearPendingViewportClaim() } strengthenRecoveryReplacementPolicy(handle, replacementPolicy) - if (replacementPolicy === 'require-replacement' && stopWaitingForPublishedHandle) { - // Why: once recovery is handed to accepted snapshots, repeated sends to the stale handle must not re-arm inventory RPCs. + if ( + replacementPolicy === 'require-replacement' && + stopWaitingForPublishedHandle && + // Why: only the epoch that handed recovery to accepted snapshots is blocked; a newer epoch is a fresh attempt, not a repeated stale send. + publishedHandleWaitEpoch === recoveryEpoch + ) { return } if (resubscribeEpoch === recoveryEpoch) { @@ -1529,6 +1576,7 @@ export function createRemoteRuntimePtyTransport( if (tabId && isWebTerminalSurfaceTabId(tabId)) { // Why: subscribe before polling so a fresh host snapshot can't land in the gap between the inventory loop and its event-driven fallback. waitForPublishedHostSessionHandle(toHostSessionTabId(tabId), resubscribeHandle) + publishedHandleWaitEpoch = recoveryEpoch } resubscribeEpoch = recoveryEpoch resubscribeRequestedHandle = null From ed4d6979b13c05bc0d5ff066b334a2efcec43d74 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:11:36 -0700 Subject: [PATCH 037/101] fix(app): await durable checkpoints before restart actions (#12433) * fix(app): await durable checkpoints before restart actions * fix(app): clear restart latch after refused reload * fix(persistence): invalidate hash after stale rename --- .../ipc/renderer-shutdown-checkpoint.test.ts | 2 +- src/main/ipc/renderer-shutdown-checkpoint.ts | 4 +- .../persistence-async-write-syscalls.test.ts | 272 ++++++++++-------- src/main/persistence.ts | 30 +- .../app-restart-checkpoint-routing.test.ts | 93 ++++++ src/preload/index.ts | 45 ++- src/preload/renderer-restart-wiring.test.ts | 8 +- src/preload/renderer-restart-wiring.ts | 21 ++ .../github-project/GhAuthErrorHelp.test.ts | 25 +- .../github-project/GhAuthErrorHelp.tsx | 14 +- 10 files changed, 346 insertions(+), 168 deletions(-) create mode 100644 src/preload/app-restart-checkpoint-routing.test.ts diff --git a/src/main/ipc/renderer-shutdown-checkpoint.test.ts b/src/main/ipc/renderer-shutdown-checkpoint.test.ts index 76fc0509eea..abbb77ab087 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.test.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.test.ts @@ -74,7 +74,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { ) expect(store.updateUI).toHaveBeenCalledWith({ activeView: 'settings' }) expect(store.flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) - // Why: a live app keeps mutating state, so draining to a stable generation would livelock. + // Why: Store fences the staged generation without draining unrelated live mutations. expect(store.flushPendingOrThrowAsync).toHaveBeenCalledWith( expect.objectContaining({ drainToStableGeneration: false }) ) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index e875f950d75..121ff86b2e3 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -23,8 +23,8 @@ function flushStagedStateWithDeadline(store: Store): Promise ({ ok: true })) diff --git a/src/main/persistence-async-write-syscalls.test.ts b/src/main/persistence-async-write-syscalls.test.ts index 2b3a6ea68b1..2d6d581599e 100644 --- a/src/main/persistence-async-write-syscalls.test.ts +++ b/src/main/persistence-async-write-syscalls.test.ts @@ -143,7 +143,7 @@ type TestStore = { waitForPendingWrite(): Promise flushOrThrow(): void flushPendingAsync(): Promise - flushPendingOrThrowAsync(): Promise + flushPendingOrThrowAsync(options?: { drainToStableGeneration?: boolean }): Promise upsertSshPtyConsumerRecovery(record: { targetId: string clientInstanceId: string @@ -185,6 +185,36 @@ function dataFile(dir: string): string { return join(dir, 'orca-data.json') } +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function recordFsCalls(dir: string): void { + fsCalls.dirPrefix = dir + fsCalls.recording = true +} + +function delayNextDataFileRename(dir: string): ReturnType & { + started: Promise +} { + const release = deferred() + const started = deferred() + let held = false + fsCalls.waitAsync = (fn, target) => { + if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { + return null + } + held = true + started.resolve() + return release.promise + } + return { ...release, started: started.promise } +} + function seedStaleBackup(dir: string): void { const path = `${dataFile(dir)}.bak.0` writeFileSync(path, '{"stale":true}', 'utf-8') @@ -232,8 +262,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { sidebarWidth: number ): Promise { store.updateUI({ sidebarWidth }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) await store.waitForPendingWrite() @@ -322,8 +351,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') store.updateUI({ sidebarWidth: 361 }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) let flushed = false fsCalls.beforeAsync = (fn, target) => { if (flushed || fn !== 'stat' || !target.endsWith('.bak.0')) { @@ -351,34 +379,19 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { it('a sync checkpoint vetoes an async write already parked on rename', async () => { const dir = makeDir() const store = await createStore(dir) - let releaseRename!: () => void - const renameRelease = new Promise((resolve) => { - releaseRename = resolve - }) - let signalRename!: () => void - const renameStarted = new Promise((resolve) => { - signalRename = resolve - }) - fsCalls.waitAsync = (fn, target) => { - if (fn !== 'rename' || target === dataFile(dir) || !target.startsWith(dataFile(dir))) { - return null - } - signalRename() - return renameRelease - } + const rename = delayNextDataFileRename(dir) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) store.updateUI({ sidebarWidth: 501 }) vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) const pending = store.waitForPendingWrite() - await renameStarted + await rename.started store.updateUI({ sidebarWidth: 502 }) store.flushOrThrow() expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(502) - releaseRename() + rename.resolve() await pending fsCalls.recording = false fsCalls.waitAsync = null @@ -391,8 +404,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const dir = makeDir() const store = await createStore(dir) const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) fsCalls.failAsync = (fn, target) => fn === 'rename' && target.startsWith(dataFile(dir)) && !target.includes('.bak.') ? Object.assign(new Error('mount disappeared'), { code: 'ENOENT' }) @@ -413,31 +425,14 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { it('the throwing async barrier drains mutations made during its write', async () => { const dir = makeDir() const store = await createStore(dir) - let releaseRename!: () => void - const renameRelease = new Promise((resolve) => { - releaseRename = resolve - }) - let signalRename!: () => void - const renameStarted = new Promise((resolve) => { - signalRename = resolve - }) - let held = false - fsCalls.waitAsync = (fn, target) => { - if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { - return null - } - held = true - signalRename() - return renameRelease - } - fsCalls.dirPrefix = dir - fsCalls.recording = true + const rename = delayNextDataFileRename(dir) + recordFsCalls(dir) store.updateUI({ sidebarWidth: 601 }) const barrier = store.flushPendingOrThrowAsync() - await renameStarted + await rename.started store.updateUI({ sidebarWidth: 602 }) - releaseRename() + rename.resolve() await barrier fsCalls.recording = false @@ -447,31 +442,14 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { it('bounds a best-effort flush to one state generation', async () => { const dir = makeDir() const store = await createStore(dir) - let releaseRename!: () => void - const renameRelease = new Promise((resolve) => { - releaseRename = resolve - }) - let signalRename!: () => void - const renameStarted = new Promise((resolve) => { - signalRename = resolve - }) - let held = false - fsCalls.waitAsync = (fn, target) => { - if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { - return null - } - held = true - signalRename() - return renameRelease - } - fsCalls.dirPrefix = dir - fsCalls.recording = true + const rename = delayNextDataFileRename(dir) + recordFsCalls(dir) store.updateUI({ sidebarWidth: 621 }) const flush = store.flushPendingAsync() - await renameStarted + await rename.started store.updateUI({ sidebarWidth: 622 }) - releaseRename() + rename.resolve() await flush fsCalls.recording = false @@ -481,33 +459,96 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(622) }) + it('keeps a bounded barrier open when its staged writer is superseded before rename', async () => { + const dir = makeDir() + const store = await createStore(dir) + const openRelease = deferred() + const openStarted = deferred() + const renameRelease = deferred() + const renameStarted = deferred() + let heldOpen = false + let heldRename = false + fsCalls.waitAsync = (fn, target) => { + if ( + !heldOpen && + fn === 'open' && + target.startsWith(dataFile(dir)) && + target.endsWith('.tmp') + ) { + heldOpen = true + openStarted.resolve() + return openRelease.promise + } + if (!heldRename && fn === 'rename' && target.startsWith(dataFile(dir))) { + heldRename = true + renameStarted.resolve() + return renameRelease.promise + } + return null + } + recordFsCalls(dir) + + store.updateUI({ sidebarWidth: 631 }) + const barrier = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await openStarted.promise + store.updateUI({ sidebarWidth: 632 }) + openRelease.resolve() + const firstOutcome = await Promise.race([ + barrier.then(() => 'settled' as const), + renameStarted.promise.then(() => 'retrying' as const) + ]) + + expect(firstOutcome).toBe('retrying') + renameRelease.resolve() + await barrier + fsCalls.recording = false + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(632) + }) + + it('rewrites a matching hash after a superseded rename installed stale state', async () => { + const dir = makeDir() + const store = await createStore(dir) + store.updateUI({ sidebarWidth: 641 }) + await store.flushPendingOrThrowAsync() + const rename = delayNextDataFileRename(dir) + recordFsCalls(dir) + + store.updateUI({ sidebarWidth: 642 }) + vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) + const staleWrite = store.waitForPendingWrite() + await rename.started + store.updateUI({ sidebarWidth: 641 }) + rename.resolve() + await staleWrite + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(642) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + fsCalls.recording = false + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(641) + }) + it('the throwing async barrier drains mutations made during sidecar I/O', async () => { const dir = makeDir() const store = await createStore(dir) - let releaseRename!: () => void - const renameRelease = new Promise((resolve) => { - releaseRename = resolve - }) - let signalRename!: () => void - const renameStarted = new Promise((resolve) => { - signalRename = resolve - }) + const renameRelease = deferred() + const renameStarted = deferred() fsCalls.waitAsync = (fn, target) => { if (fn !== 'rename' || !target.includes('orca-github-cache.json.')) { return null } - signalRename() - return renameRelease + renameStarted.resolve() + return renameRelease.promise } - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) store.updateUI({ sidebarWidth: 611 }) store.setGitHubCache({ pr: {}, issue: {} }) const barrier = store.flushPendingOrThrowAsync() - await renameStarted + await renameStarted.promise store.updateUI({ sidebarWidth: 612 }) - releaseRename() + renameRelease.resolve() await barrier fsCalls.recording = false @@ -522,42 +563,35 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { utimesSync(`${dataFile(dir)}.bak.0`, almostDueSeconds, almostDueSeconds) const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') const statCall = `stat:${dataFile(dir)}.bak.0` - let releaseRotation!: () => void - const rotationRelease = new Promise((resolve) => { - releaseRotation = resolve - }) - let signalRotation!: () => void - const rotationStarted = new Promise((resolve) => { - signalRotation = resolve - }) + const rotationRelease = deferred() + const rotationStarted = deferred() let held = false fsCalls.waitAsync = (fn, target) => { if (held || fn !== 'stat' || target !== `${dataFile(dir)}.bak.0`) { return null } held = true - signalRotation() - return rotationRelease + rotationStarted.resolve() + return rotationRelease.promise } - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) store.updateUI({ sidebarWidth: 371 }) vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) const firstWrite = store.waitForPendingWrite() let allWrites = firstWrite try { - await rotationStarted + await rotationStarted.promise store.updateUI({ sidebarWidth: 372 }) store.flushOrThrow() store.updateUI({ sidebarWidth: 373 }) vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) allWrites = store.waitForPendingWrite() expect(fsCalls.asyncCalls.filter((call) => call === statCall)).toHaveLength(1) - releaseRotation() + rotationRelease.resolve() await Promise.all([firstWrite, allWrites]) } finally { - releaseRotation() + rotationRelease.resolve() await allWrites fsCalls.recording = false fsCalls.waitAsync = null @@ -579,8 +613,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const expectedTarget = `${dataFile(dir)}${targetSuffix}` store.updateUI({ sidebarWidth: 363 }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) let flushed = false fsCalls.beforeAsync = (fn, target) => { if (flushed || fn !== expectedFn || target !== expectedTarget) { @@ -688,8 +721,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const dir = makeDir() const store = await createStore(dir) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { await store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) } finally { @@ -710,8 +742,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const store = await createStore(dir) const writeError = Object.assign(new Error('profile mount rejected write'), { code: 'EIO' }) const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) fsCalls.failAsync = (fn, target) => fn === 'open' && target.startsWith(`${dataFile(dir)}.`) ? writeError : null @@ -730,8 +761,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const store = await createStore(dir) await store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { await store.removeSshPtyConsumerRecovery('ssh-1') } finally { @@ -750,8 +780,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { const store = await createStore(dir) store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { await store.markSshRemotePtyLeasesAsync('ssh-1', 'detached') } finally { @@ -774,8 +803,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { // Why: a PTY that exits mid-reattach is terminated before the batch write lands; it must stay dead. store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-4', state: 'terminated' }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { await store.markSshRemotePtyLeasesAttachedAsync('ssh-1', ['pty-1', 'pty-2', 'pty-4']) } finally { @@ -799,39 +827,32 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { it('keeps async writers serialized across a synchronous shutdown flush', async () => { const dir = makeDir() const store = await createStore(dir) - let signalFirstOpen!: () => void - const firstOpen = new Promise((resolve) => { - signalFirstOpen = resolve - }) - let releaseFirstOpen!: () => void - const firstOpenRelease = new Promise((resolve) => { - releaseFirstOpen = resolve - }) + const firstOpen = deferred() + const firstOpenRelease = deferred() let held = false fsCalls.waitAsync = (fn, target) => { if (held || fn !== 'open' || !target.endsWith('.tmp')) { return null } held = true - signalFirstOpen() - return firstOpenRelease + firstOpen.resolve() + return firstOpenRelease.promise } - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { const firstWrite = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) - await firstOpen + await firstOpen.promise store.flushOrThrow() const secondWrite = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-2')) await Promise.resolve() await Promise.resolve() expect(fsCalls.asyncCalls.filter((call) => call.startsWith('open:'))).toHaveLength(1) - releaseFirstOpen() + firstOpenRelease.resolve() await Promise.all([firstWrite, secondWrite]) } finally { - releaseFirstOpen() + firstOpenRelease.resolve() fsCalls.recording = false fsCalls.waitAsync = null } @@ -885,8 +906,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { seedStaleBackup(dir) store.updateUI({ sidebarWidth: 331 }) - fsCalls.dirPrefix = dir - fsCalls.recording = true + recordFsCalls(dir) try { store.flushOrThrow() } finally { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 077d9b871a7..f74f5d92644 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2786,6 +2786,7 @@ export class Store { private quitFlushPromise: Promise | null = null // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. private lastWrittenStateHash: string | null = null + private lastDurableWriteGeneration = -1 private firstPendingSaveAt: number | null = null private githubCacheDirty = false private githubCacheGeneration = 0 @@ -3941,6 +3942,7 @@ export class Store { const { payload, stateHash } = this.buildStateToSave() // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. if (stateHash === this.lastWrittenStateHash) { + this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) return } const dataFile = this.dataFile @@ -3976,9 +3978,14 @@ export class Store { this.inFlightAsyncTmpFile = null } } - // Why re-check gen: a sync flush during the rename await may have written fresher state; don't record a stale hash over it. + // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. if (renamed && this.writeGeneration === gen) { this.lastWrittenStateHash = stateHash + } else if (renamed) { + this.lastWrittenStateHash = null + } + if (renamed) { + this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) } } finally { if (!renamed) { @@ -4020,6 +4027,10 @@ export class Store { writeFileDurableSync(tmpFile, dataFile, payload) renamed = true this.lastWrittenStateHash = stateHash + this.lastDurableWriteGeneration = Math.max( + this.lastDurableWriteGeneration, + this.writeGeneration + ) } finally { if (!renamed) { try { @@ -7297,7 +7308,7 @@ export class Store { if (this.writesFrozen || this.quitFlushStarted) { return Promise.reject(new Error('Cannot flush while persistence is finalized')) } - return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration) + return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration, true) } // Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are @@ -7323,8 +7334,10 @@ export class Store { private async flushCurrentStateAsync( final: boolean, signal?: AbortSignal, - drainToStableGeneration = true + drainToStableGeneration = true, + requireInitialGenerationDurable = false ): Promise { + const requiredDurableGeneration = requireInitialGenerationDurable ? this.writeGeneration : null for (;;) { if (signal?.aborted) { throw new Error('Persistence flush aborted') @@ -7351,7 +7364,16 @@ export class Store { if (signal?.aborted) { throw new Error('Persistence flush aborted') } - if (!drainToStableGeneration || generation === this.writeGeneration) { + if (!drainToStableGeneration) { + if ( + requiredDurableGeneration === null || + this.lastDurableWriteGeneration >= requiredDurableGeneration + ) { + break + } + continue + } + if (generation === this.writeGeneration) { break } } diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts new file mode 100644 index 00000000000..9f64569e3ca --- /dev/null +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreloadApi } from './api-types' +import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT +} from '../shared/updater-renderer-events' + +const { exposeInMainWorld, invoke, on, removeListener, send, sendSync } = vi.hoisted(() => ({ + exposeInMainWorld: vi.fn(), + invoke: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + sendSync: vi.fn() +})) + +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener, send, sendSync }, + webFrame: { + getZoomFactor: vi.fn(() => 1), + setZoomFactor: vi.fn(), + setVisualZoomLevelLimits: vi.fn() + }, + webUtils: { getPathForFile: vi.fn(() => '') } +})) + +vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) + +describe('native preload destructive app actions', () => { + const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') + let eventTarget: EventTarget + + beforeEach(() => { + vi.resetModules() + exposeInMainWorld.mockReset() + invoke.mockReset() + on.mockReset() + removeListener.mockReset() + send.mockReset() + sendSync.mockReset() + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true }) + eventTarget = new EventTarget() + vi.stubGlobal('window', eventTarget) + vi.stubGlobal('document', { addEventListener: vi.fn() }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (originalContextIsolated) { + Object.defineProperty(process, 'contextIsolated', originalContextIsolated) + } else { + Reflect.deleteProperty(process, 'contextIsolated') + } + }) + + const loadApi = async (): Promise => { + await import('./index') + return exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + } + + for (const action of ['reload', 'relaunch'] as const) { + it(`prepares and awaits durability before ${action}`, async () => { + const api = await loadApi() + const calls: string[] = [] + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, () => calls.push('prepared')) + invoke.mockImplementation(async (channel: string) => { + calls.push(channel) + return channel === 'app:await-before-unload-checkpoint' ? { ok: true } : undefined + }) + + await api.app[action]() + + expect(calls).toEqual(['prepared', 'app:await-before-unload-checkpoint', `app:${action}`]) + }) + + it(`refuses ${action} when the durable checkpoint fails`, async () => { + const api = await loadApi() + const aborted = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + invoke.mockImplementation(async (channel: string) => + channel === 'app:await-before-unload-checkpoint' ? { ok: false } : undefined + ) + + await expect(api.app[action]()).rejects.toThrow( + 'Failed to persist renderer state before unload.' + ) + + expect(invoke).not.toHaveBeenCalledWith(`app:${action}`) + expect(aborted).toHaveBeenCalledTimes(1) + }) + } +}) diff --git a/src/preload/index.ts b/src/preload/index.ts index de829aafe37..13a450bce83 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -241,11 +241,7 @@ import type { } from '../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' import type { AgentType } from '../shared/native-chat-types' -import { - ORCA_APP_RESTART_ABORTED_EVENT, - ORCA_APP_RESTART_STARTED_EVENT, - ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT -} from '../shared/updater-renderer-events' +import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' import { ORCA_INTERNAL_FILE_DRAG_TYPE, createNativeFileDropPayload, @@ -281,11 +277,9 @@ import type { } from '../shared/crash-reporting' import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' +import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' import { - createUpdaterQuitAbortRelay, - prepareRendererForAppRestart -} from '../shared/renderer-restart-preparation' -import { + prepareAndInvokeAppRestart, prepareAndInvokeUpdaterInstall, registerRendererRestartIpcRelays } from './renderer-restart-wiring' @@ -486,21 +480,24 @@ const api = { getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), getFeatureWallAssetBaseUrl: (): Promise => ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), - relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), - restart: async (): Promise => { - await prepareRendererForAppRestart(window, { - startedEventName: ORCA_APP_RESTART_STARTED_EVENT, - abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, - awaitCheckpoint: awaitBeforeUnloadCheckpoint - }) - try { - return await ipcRenderer.invoke('app:restart') - } catch (error) { - window.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) - throw error - } - }, - reload: (): Promise => ipcRenderer.invoke('app:reload'), + relaunch: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:relaunch'), + awaitBeforeUnloadCheckpoint + ), + restart: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:restart'), + awaitBeforeUnloadCheckpoint + ), + reload: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:reload'), + awaitBeforeUnloadCheckpoint + ), stageBeforeUnloadSync: (args: Parameters[0]) => { const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as { ok?: unknown diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index 0198c72a44f..06be6537fcb 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events' -import { ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT +} from '../shared/updater-renderer-events' import { prepareAndInvokeUpdaterInstall, registerRendererRestartIpcRelays @@ -10,6 +13,7 @@ describe('renderer restart wiring', () => { it('relays updater status and prevented unload events', () => { const eventTarget = new EventTarget() const unloadPrevented = vi.fn() + const restartAborted = vi.fn() const handleStatus = vi.fn() const listeners = new Map void>() const ipcRenderer = { @@ -19,6 +23,7 @@ describe('renderer restart wiring', () => { }) } as unknown as Parameters[0] eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, unloadPrevented) + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, restartAborted) registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus }) listeners.get('updater:status')?.({}, { state: 'error', message: 'install failed' }) @@ -27,6 +32,7 @@ describe('renderer restart wiring', () => { expect(ipcRenderer.on).toHaveBeenCalledTimes(2) expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' }) expect(unloadPrevented).toHaveBeenCalledTimes(1) + expect(restartAborted).toHaveBeenCalledTimes(1) }) it('marks preparation before invoking main and aborts on IPC failure', async () => { diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index 254d386403a..9b140e7116b 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -6,6 +6,8 @@ import { } from '../shared/renderer-restart-preparation' import type { UpdateStatus } from '../shared/types' import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' @@ -20,6 +22,7 @@ export function registerRendererRestartIpcRelays( }) ipcRenderer.on('window:unload-prevented', () => { eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) }) } @@ -42,3 +45,21 @@ export async function prepareAndInvokeUpdaterInstall( throw error } } + +export async function prepareAndInvokeAppRestart( + eventTarget: EventTarget, + invoke: () => Promise, + awaitCheckpoint: () => Promise +): Promise { + await prepareRendererForAppRestart(eventTarget, { + startedEventName: ORCA_APP_RESTART_STARTED_EVENT, + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + awaitCheckpoint + }) + try { + await invoke() + } catch (error) { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + throw error + } +} diff --git a/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts b/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts index da666d54523..248783de9e0 100644 --- a/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts +++ b/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts @@ -1,11 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { GhAuthDiagnostic } from '../../../../shared/github-auth-types' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -import { buildRemediation } from './GhAuthErrorHelp' +import { buildRemediation, reloadOrcaRenderer } from './GhAuthErrorHelp' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) function diagnostic(overrides: Partial = {}): GhAuthDiagnostic { return { @@ -57,4 +62,20 @@ describe('GitHub Project auth remediation host routing', () => { } ]) }) + + it('does not force navigation when the checkpointed reload is refused', async () => { + const reload = vi.fn(() => Promise.reject(new Error('checkpoint failed'))) + const locationReload = vi.fn() + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { app: { reload } }, + location: { reload: locationReload } + }) + + reloadOrcaRenderer() + await vi.waitFor(() => expect(errors).toHaveBeenCalledTimes(1)) + + expect(reload).toHaveBeenCalledTimes(1) + expect(locationReload).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx b/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx index 30d1ea61b16..7bbeff291d7 100644 --- a/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx +++ b/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx @@ -40,14 +40,12 @@ function refreshCommandForHost(host: string | null | undefined): string { // macOS/Linux vs PowerShell on Windows. const IS_WINDOWS = typeof navigator !== 'undefined' && /Win(dows|32|64)/i.test(navigator.userAgent) -function reloadOrcaRenderer(): void { - const reload = window.api.app.reload - if (typeof reload !== 'function') { - window.location.reload() - return - } - void reload().catch(() => { - window.location.reload() +export function reloadOrcaRenderer(): void { + void window.api.app.reload().catch((error) => { + console.error( + '[github-projects] Renderer reload refused:', + error instanceof Error ? error.name : typeof error + ) }) } From e8d4818b5a0b4e349ab7360acff602e5459adc40 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:18:15 -0700 Subject: [PATCH 038/101] test(env): keep happy-dom MutationObserver callbacks alive across GC (#12438) happy-dom holds each observer's internal callback in a WeakRef, so a GC pause under sharded CI load permanently kills a still-connected observer. That is what made the activity portal readiness test flake: once the observer died, no amount of re-applying ready DOM could release the latch. Co-authored-by: Orca --- ...py-dom-mutation-observer-retention.test.ts | 72 +++++++++++++++++ .../happy-dom-mutation-observer-retention.ts | 81 +++++++++++++++++++ config/vitest.config.ts | 2 + 3 files changed, 155 insertions(+) create mode 100644 config/scripts/happy-dom-mutation-observer-retention.test.ts create mode 100644 config/scripts/happy-dom-mutation-observer-retention.ts diff --git a/config/scripts/happy-dom-mutation-observer-retention.test.ts b/config/scripts/happy-dom-mutation-observer-retention.test.ts new file mode 100644 index 00000000000..475a84c9b31 --- /dev/null +++ b/config/scripts/happy-dom-mutation-observer-retention.test.ts @@ -0,0 +1,72 @@ +/** @vitest-environment happy-dom */ +import { describe, expect, it } from 'vitest' + +import { + installHappyDomMutationObserverRetention, + retainedMutationCallbackCount +} from './happy-dom-mutation-observer-retention' + +function readListenerCallbacks(target: Node): unknown[] { + const listenersSymbol = Object.getOwnPropertySymbols(target).find( + (candidate) => candidate.description === 'mutationListeners' + ) + const listeners = listenersSymbol + ? (target as unknown as Record)[listenersSymbol] + : [] + if (!Array.isArray(listeners)) { + return [] + } + return listeners.map((listener: { callback?: { deref: () => unknown } }) => + listener.callback?.deref() + ) +} + +describe('happy-dom MutationObserver retention', () => { + it('pins the internal callback that happy-dom only holds weakly', () => { + expect(installHappyDomMutationObserverRetention()).toBe(true) + const target = document.createElement('div') + document.body.append(target) + const observer = new MutationObserver(() => {}) + observer.observe(target, { childList: true, subtree: true }) + + const callbacks = readListenerCallbacks(target) + expect(callbacks.length).toBe(1) + expect(callbacks[0]).toBeTypeOf('function') + expect(retainedMutationCallbackCount(observer)).toBe(1) + + observer.disconnect() + expect(retainedMutationCallbackCount(observer)).toBe(0) + target.remove() + }) + + it('keeps delivering records after the weak callback would have been collected', async () => { + installHappyDomMutationObserverRetention() + const target = document.createElement('div') + document.body.append(target) + let deliveries = 0 + const observer = new MutationObserver(() => { + deliveries += 1 + }) + observer.observe(target, { childList: true, subtree: true }) + + target.replaceChildren(document.createElement('div')) + await Promise.resolve() + expect(deliveries).toBe(1) + + const collectGarbage = (globalThis as { gc?: () => void }).gc + if (collectGarbage) { + for (let round = 0; round < 5; round += 1) { + await new Promise((resolve) => setTimeout(resolve, 1)) + collectGarbage() + } + await new Promise((resolve) => setTimeout(resolve, 1)) + } + + target.replaceChildren(document.createElement('span')) + await Promise.resolve() + expect(deliveries).toBe(2) + + observer.disconnect() + target.remove() + }) +}) diff --git a/config/scripts/happy-dom-mutation-observer-retention.ts b/config/scripts/happy-dom-mutation-observer-retention.ts new file mode 100644 index 00000000000..a315b3d520c --- /dev/null +++ b/config/scripts/happy-dom-mutation-observer-retention.ts @@ -0,0 +1,81 @@ +// Why: happy-dom stores each MutationObserver's internal callback in a WeakRef, so any GC pause +// under parallel test load silently and permanently stops a still-connected observer. Browsers +// keep that callback reachable for as long as the observer observes; mirror that lifetime here so +// DOM-driven tests never lose mutation records mid-run. + +type HappyDomMutationListener = { + callback?: { deref: () => unknown } +} + +type PatchableMutationObserver = { + observe: (target: Node, options?: MutationObserverInit) => void + disconnect: () => void +} + +const MUTATION_LISTENERS_SYMBOL_DESCRIPTION = 'mutationListeners' +const RETENTION_INSTALLED = Symbol.for('orca.happyDomMutationObserverRetention') + +const retainedCallbacks = new WeakMap>() + +function readMutationListeners(target: Node): HappyDomMutationListener[] { + const listenersSymbol = Object.getOwnPropertySymbols(target).find( + (candidate) => candidate.description === MUTATION_LISTENERS_SYMBOL_DESCRIPTION + ) + if (!listenersSymbol) { + return [] + } + const listeners = (target as unknown as Record)[listenersSymbol] + return Array.isArray(listeners) ? (listeners as HappyDomMutationListener[]) : [] +} + +/** Number of internal callbacks pinned for `observer`; drops to 0 once it disconnects. */ +export function retainedMutationCallbackCount(observer: MutationObserver): number { + return retainedCallbacks.get(observer)?.size ?? 0 +} + +export function installHappyDomMutationObserverRetention(): boolean { + const observerClass = (globalThis as { MutationObserver?: typeof MutationObserver }) + .MutationObserver + if (!observerClass) { + return false + } + const prototype = observerClass.prototype as unknown as PatchableMutationObserver & + Record + if (prototype[RETENTION_INSTALLED] === true) { + return true + } + const observe = prototype.observe + const disconnect = prototype.disconnect + + prototype.observe = function patchedObserve( + this: object, + target: Node, + options?: MutationObserverInit + ): void { + const existing = new Set(readMutationListeners(target)) + observe.call(this as unknown as PatchableMutationObserver, target, options) + const pinned = retainedCallbacks.get(this) ?? new Set() + for (const listener of readMutationListeners(target)) { + if (existing.has(listener)) { + continue + } + const callback = listener.callback?.deref() + if (callback) { + pinned.add(callback) + } + } + if (pinned.size > 0) { + retainedCallbacks.set(this, pinned) + } + } + + prototype.disconnect = function patchedDisconnect(this: object): void { + disconnect.call(this as unknown as PatchableMutationObserver) + retainedCallbacks.delete(this) + } + + prototype[RETENTION_INSTALLED] = true + return true +} + +installHappyDomMutationObserverRetention() diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 1146cd4a5ca..735e1b2a0af 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ environment: 'node', // Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's. execArgv: ['--no-experimental-webstorage'], + // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. + setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')], include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx', From e8d30431074f8723254aafcad32d6e2bf91a6f3e Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:23:33 -0700 Subject: [PATCH 039/101] fix(mobile): stop unrenewed-grace rotation churn and gate cadence gaps (#12426) - skip proactive rotation when the resume confirmation reports renewed=false (a re-resume provably returns the same unchanged deadline; rotating churned one session replacement per clamp floor, ~60/hour, until a fresh credential) - armCredentialReprobe under a held gate mints the tick's pass token so the effective reprobe cadence stays 60s..15min instead of doubling to ~30min - registerFailure honors scheduleRetry=false in gate branches: no reprobe timer is armed while backgrounded/stopped; foreground resume re-arms - extract RelayRetryDelays and supervisor test fakes into their own modules (max-lines) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../mobile-endpoint-supervisor-test-fakes.ts | 169 +++++++++++++++ .../mobile-endpoint-supervisor.test.ts | 199 +++--------------- .../transport/mobile-endpoint-supervisor.ts | 5 +- .../mobile-relay-reconnect-controller.test.ts | 42 ++++ .../mobile-relay-reconnect-controller.ts | 76 +++---- .../transport/mobile-relay-retry-delays.ts | 45 ++++ 6 files changed, 319 insertions(+), 217 deletions(-) create mode 100644 mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts create mode 100644 mobile/src/transport/mobile-relay-retry-delays.ts diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts new file mode 100644 index 00000000000..d483c6536e8 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -0,0 +1,169 @@ +import { vi } from 'vitest' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor' +import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState, HostProfile, RpcResponse } from './types' + +export class FakeSession implements RpcClient { + readonly sendRequest = vi.fn( + async (_method: string, _params?: unknown): Promise => ({ + id: 'rpc-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) + readonly subscribe = vi.fn(() => () => {}) + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + private readonly listeners = new Set<(state: ConnectionState) => void>() + + constructor(private state: ConnectionState) {} + + getState = () => this.state + getReconnectAttempt = () => 0 + getLastConnectedAt = () => null + onStateChange = (listener: (state: ConnectionState) => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publishState(state: ConnectionState): void { + this.state = state + for (const listener of this.listeners) { + listener(state) + } + } +} + +export class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { + constructor( + state: ConnectionState, + private readonly failure: Error | null = null, + private readonly resumeExpiry = Date.now() + 30 * 24 * 3_600_000, + private readonly renewed = true + ) { + super(state) + } + // Why: production-realistic defaults — fictional fake values hid three + // live defects in this subsystem (latch, churn, int32 timer overflow). + getAttachDeadlineAt = () => Date.now() + 10_000 + getResumeExpiresAt = () => this.resumeExpiry + getResumeConfirmation = () => ({ + v: 1 as const, + reqId: 'confirm-1', + currentVersion: 2, + acceptedAs: this.renewed ? ('current' as const) : ('grace' as const), + renewed: this.renewed, + resumeExpiresAt: this.resumeExpiry + }) + getFailure = () => this.failure +} + +export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { + private path: MobileConnectionPath + private generation = 1 + + constructor(state: ConnectionState, path: MobileConnectionPath) { + super(state) + this.path = path + } + + migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { + if (session.getState() !== 'connected') { + session.close() + throw new Error(`replacement session ${session.getState()}`) + } + this.path = path + this.generation += 1 + this.publishState('connected') + }) + suspendActiveSession = vi.fn(() => this.publishState('disconnected')) + getActivePath = () => this.path + getGeneration = () => this.generation +} + +export const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} +export const host: HostProfile = { + id: 'host-1', + name: 'Blue Whale', + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, + { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } + ], + relayHostId: relay.relayHostId, + relay +} +export const bundle: MobileRelayCredentialBundle = { + v: 1, + hostId: host.id, + deviceToken: host.deviceToken, + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 2, + expiresAt: Number.MAX_SAFE_INTEGER + } +} + +export function dependencies( + overrides: Partial = {} +): MobileEndpointSupervisorDependencies { + return { + openDirect: vi.fn(() => new FakeSession('connected')), + openRelay: vi.fn(() => new FakeRelaySession('connected')), + resolveRelay: vi.fn(async ({ relay }) => relay), + readBundle: vi.fn(async () => bundle), + writeBundle: vi.fn(async () => {}), + saveHost: vi.fn(async () => {}), + now: Date.now, + randomBytes: (length) => new Uint8Array(length).fill(1), + setTimer: setTimeout, + clearTimer: clearTimeout, + ...overrides + } +} + +export function mockCredentialRotation(logical: FakeLogicalClient): void { + let installResult: Record | null = null + logical.sendRequest.mockImplementation(async (method, params) => { + const request = params as { installReqId?: string; reqId?: string } + if (method === 'pairing.provisionRelay') { + installResult = { + v: 1, + reqId: request.reqId, + authorizationMode: 'authenticated-direct', + currentVersion: 3, + resumeExpiresAt: Date.now() + 300_000, + graceExpiresAt: Date.now() + 60_000 + } + return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } } + } + return { + id: 'rpc-1', + ok: true, + result: { + v: 1, + relay, + installStatus: installResult + ? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult } + : { v: 1, reqId: request.installReqId, state: 'not-found' } + }, + _meta: { runtimeId: 'runtime-1' } + } + }) +} diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index e6d0ff8bb32..d1b8b6918cd 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -2,180 +2,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' import { RelayOuterError } from './mobile-relay-e2ee-link' -import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { - MobileEndpointSupervisor, - type MobileEndpointSupervisorDependencies -} from './mobile-endpoint-supervisor' -import type { RpcClient } from './rpc-client' -import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' -import type { ConnectionState, HostProfile, RpcResponse } from './types' + bundle, + dependencies, + FakeLogicalClient, + FakeRelaySession, + FakeSession, + host, + mockCredentialRotation, + relay +} from './mobile-endpoint-supervisor-test-fakes' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) -class FakeSession implements RpcClient { - readonly sendRequest = vi.fn( - async (_method: string, _params?: unknown): Promise => ({ - id: 'rpc-1', - ok: true, - result: {}, - _meta: { runtimeId: 'runtime-1' } - }) - ) - readonly subscribe = vi.fn(() => () => {}) - readonly updateTerminalSubscriptionViewport = vi.fn() - readonly notifyForeground = vi.fn() - readonly close = vi.fn() - private readonly listeners = new Set<(state: ConnectionState) => void>() - - constructor(private state: ConnectionState) {} - - getState = () => this.state - getReconnectAttempt = () => 0 - getLastConnectedAt = () => null - onStateChange = (listener: (state: ConnectionState) => void) => { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } - - publishState(state: ConnectionState): void { - this.state = state - for (const listener of this.listeners) { - listener(state) - } - } -} - -class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { - constructor( - state: ConnectionState, - private readonly failure: Error | null = null, - private readonly resumeExpiry = Date.now() + 30 * 24 * 3_600_000 - ) { - super(state) - } - // Why: production-realistic defaults — fictional fake values hid three - // live defects in this subsystem (latch, churn, int32 timer overflow). - getAttachDeadlineAt = () => Date.now() + 10_000 - getResumeExpiresAt = () => this.resumeExpiry - getResumeConfirmation = () => ({ - v: 1 as const, - reqId: 'confirm-1', - currentVersion: 2, - acceptedAs: 'current' as const, - renewed: true, - resumeExpiresAt: this.resumeExpiry - }) - getFailure = () => this.failure -} - -class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { - private path: MobileConnectionPath - private generation = 1 - - constructor(state: ConnectionState, path: MobileConnectionPath) { - super(state) - this.path = path - } - - migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { - if (session.getState() !== 'connected') { - session.close() - throw new Error(`replacement session ${session.getState()}`) - } - this.path = path - this.generation += 1 - this.publishState('connected') - }) - suspendActiveSession = vi.fn(() => this.publishState('disconnected')) - getActivePath = () => this.path - getGeneration = () => this.generation -} - -const relay = { - v: 1 as const, - directorUrl: 'https://relay.onorca.dev', - cellUrl: 'https://relay-c1.onorca.dev', - assignmentEpoch: 7, - relayHostId: 'AbCdEf0123_-xyZ9', - e2eeFraming: 2 as const -} -const host: HostProfile = { - id: 'host-1', - name: 'Blue Whale', - endpoint: 'ws://192.168.1.10:6768', - deviceToken: 'device-token', - publicKeyB64: 'A'.repeat(44), - lastConnected: 1, - endpoints: [ - { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, - { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } - ], - relayHostId: relay.relayHostId, - relay -} -const bundle: MobileRelayCredentialBundle = { - v: 1, - hostId: host.id, - deviceToken: host.deviceToken, - current: { - token: 'A'.repeat(43), - hash: 'B'.repeat(43), - version: 2, - expiresAt: Number.MAX_SAFE_INTEGER - } -} - -function dependencies( - overrides: Partial = {} -): MobileEndpointSupervisorDependencies { - return { - openDirect: vi.fn(() => new FakeSession('connected')), - openRelay: vi.fn(() => new FakeRelaySession('connected')), - resolveRelay: vi.fn(async ({ relay }) => relay), - readBundle: vi.fn(async () => bundle), - writeBundle: vi.fn(async () => {}), - saveHost: vi.fn(async () => {}), - now: Date.now, - randomBytes: (length) => new Uint8Array(length).fill(1), - setTimer: setTimeout, - clearTimer: clearTimeout, - ...overrides - } -} - -function mockCredentialRotation(logical: FakeLogicalClient): void { - let installResult: Record | null = null - logical.sendRequest.mockImplementation(async (method, params) => { - const request = params as { installReqId?: string; reqId?: string } - if (method === 'pairing.provisionRelay') { - installResult = { - v: 1, - reqId: request.reqId, - authorizationMode: 'authenticated-direct', - currentVersion: 3, - resumeExpiresAt: Date.now() + 300_000, - graceExpiresAt: Date.now() + 60_000 - } - return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } } - } - return { - id: 'rpc-1', - ok: true, - result: { - v: 1, - relay, - installStatus: installResult - ? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult } - : { v: 1, reqId: request.installReqId, state: 'not-found' } - }, - _meta: { runtimeId: 'runtime-1' } - } - }) -} - describe('mobile endpoint supervisor', () => { beforeEach(() => { vi.useFakeTimers() @@ -805,6 +647,27 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) + it('skips forced rotation for a session resumed without renewal', async () => { + // Why: renewed=false means a re-resume provably returns the same unchanged + // deadline; rotating anyway churned one session replacement per clamp floor. + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi.fn( + () => new FakeRelaySession('connected', null, Date.now() + 31_000, false) + ) + const deps = dependencies({ + openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + expect(openRelay).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(openRelay).toHaveBeenCalledTimes(1) + supervisor.stop() + }) + it('keeps a fatal lease-replacement gate after the active relay later drops', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') const openRelay = vi diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index 77ffbbde3db..be295fdcdda 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -265,8 +265,11 @@ export class MobileEndpointSupervisor { // Why: rotate against the resume credential's expiry, never the hello's // leaseExpiresAt — that field is the cell's ~10s attach-reservation // deadline, and using it forced a session replacement every second. + // Why: renewed=false means a re-resume provably returns the same + // unchanged deadline — rotating then just churns one replacement per + // clamp floor until a fresh credential arrives over direct or disk. this.leaseRotation.scheduleFromLease( - this.stopped || !this.foreground + this.stopped || !this.foreground || confirmation?.renewed === false ? null : (confirmation?.resumeExpiresAt ?? session.getResumeExpiresAt()) ) diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts index cc640d4d7e7..4f50878c6d5 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts @@ -111,6 +111,48 @@ describe('relay reconnect controller', () => { expect(onRetry).toHaveBeenCalledTimes(3) }) + it('mints the gate pass when arming a no-bundle reprobe under a held gate', () => { + // Why: an external-signal gate never records rejected versions, so the + // no-dialable-bundle path must still mint the tick's pass token — a plain + // cooldown tick bounces off shouldDefer and doubles the effective cadence. + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.registerFailure(new MobileE2EEAuthenticationError()) + expect(reconnect.shouldDefer()).toBe(true) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + expect(reconnect.shouldDefer()).toBe(false) + // The gated attempt finds no dialable credential at all and re-arms. + reconnect.armCredentialReprobe() + + vi.advanceTimersByTime(120_000) + expect(onRetry).toHaveBeenCalledTimes(2) + expect(reconnect.shouldDefer()).toBe(false) + }) + + it('does not arm a gate reprobe timer when the supervisor declined retries', () => { + // Why: scheduleRetry=false means background or stopped — a tick firing + // there wakes nothing useful, and the next foreground resume re-arms. + const onRetry = vi.fn() + const reconnect = createController(onRetry) + const logical = { getState: () => 'disconnected' } as never + + reconnect.registerFailure(new MobileE2EEAuthenticationError(), false) + expect(vi.getTimerCount()).toBe(0) + reconnect.registerFailure(new RelayOuterError(4401), false) + expect(vi.getTimerCount()).toBe(0) + reconnect.registerFailure(new RelayOuterError(4429), false) + expect(vi.getTimerCount()).toBe(0) + vi.runAllTimers() + expect(onRetry).not.toHaveBeenCalled() + + // Resume restores the gated cadence instead of stalling forever. + reconnect.handleForeground(logical, false) + expect(reconnect.shouldDefer()).toBe(true) + expect(vi.getTimerCount()).toBe(1) + }) + it('does not let an orphaned reprobe timer swallow the next fast backoff', () => { const onRetry = vi.fn() const reconnect = createController(onRetry) diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index f2a7cad6b05..9ec5c1398bc 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -6,24 +6,10 @@ import { import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { RelayOuterError } from './mobile-relay-e2ee-link' +import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ConnectionState } from './types' -// Why: relay resume closes and silent cellular NAT rebinds otherwise cause -// immediate re-dials that ping-pong the phone between connected and disconnected. -const RELAY_BACKOFF_MIN_MS = 250 -const RELAY_BACKOFF_BASE_MS = 500 -const RELAY_BACKOFF_CEILING_MS = 30_000 -const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS -const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000 -const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 -// Why: gates must slow recovery down, never end it — a relay-only phone has no -// direct path to refresh credentials, so a timerless gate is a permanent outage. -// The cadence escalates and jitters so a permanently revoked device is not a -// forever one-minute beacon and a fleet-wide gating event cannot phase-align. -const RELAY_GATE_REPROBE_BASE_MS = 60_000 -const RELAY_GATE_REPROBE_CEILING_MS = 15 * 60_000 - export type RelayReconnectDependencies = { now: () => number randomBytes: (length: number) => Uint8Array @@ -43,11 +29,14 @@ export class RelayReconnectController { private gateReprobePending = false private gateReprobeStreak = 0 private readonly rejectedCredentialVersions = new Set() + private readonly delays: RelayRetryDelays constructor( private readonly dependencies: RelayReconnectDependencies, private readonly onRetry: (forceReplacement?: boolean) => void - ) {} + ) { + this.delays = new RelayRetryDelays(dependencies.randomBytes) + } handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void { if (!wasForeground) { @@ -156,10 +145,17 @@ export class RelayReconnectController { this.scheduleGateReprobe() return } + if (this.recoveryGate) { + // Why: under a held gate the tick must mint its pass token — a plain + // cooldown tick bounces off shouldDefer and doubles the effective cadence. + this.clearTimer() + this.scheduleGateReprobe() + return + } // Why: a merely missing or expired bundle must not enter the credential // gate — that gate forces a rotation on the next direct connect. A plain // cooldown retries the read on the same escalating cadence. - const delay = this.gateReprobeDelayMs() + const delay = this.delays.gateReprobeDelayMs(this.gateReprobeStreak) this.nextAttemptAt = this.dependencies.now() + delay this.clearTimer() this.scheduleReprobeTick(delay, false) @@ -219,9 +215,12 @@ export class RelayReconnectController { this.recoveryGate === 'fresh-credential' || (this.recoveryGate === 'external-signal' && recovery?.kind !== 'disable-relay-credential') ) { - // Why: a failed reprobe stays gated, but the slow cadence must keep going. + // Why: a failed reprobe stays gated, but the slow cadence must keep going + // — unless the supervisor is backgrounded/stopped; resume re-arms it. this.clearTimer() - this.scheduleGateReprobe() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } const now = this.dependencies.now() @@ -236,7 +235,9 @@ export class RelayReconnectController { this.activeRelayConnectedAt = null this.consecutiveFailures += 1 const delay = - recovery?.kind === 'retry-after-host-offline' ? this.hostOfflineDelayMs() : this.delayMs() + recovery?.kind === 'retry-after-host-offline' + ? this.delays.hostOfflineDelayMs() + : this.delays.transportDelayMs(this.consecutiveFailures) this.nextAttemptAt = now + delay if (error instanceof MobileE2EEAuthenticationError) { // Why: an E2EE rejection is usually pairing revocation, but it also fires @@ -244,7 +245,9 @@ export class RelayReconnectController { // reprobe slowly instead of waiting forever for a UI nudge. this.recoveryGate = 'external-signal' this.clearTimer() - this.scheduleGateReprobe() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } if (recovery?.kind === 'disable-relay-credential') { @@ -252,7 +255,9 @@ export class RelayReconnectController { // alive — the caller re-reads durable state before each gated attempt. this.recoveryGate = 'fresh-credential' this.clearTimer() - this.scheduleGateReprobe() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } if (recovery?.kind === 'retry-after-host-offline') { @@ -316,7 +321,7 @@ export class RelayReconnectController { } private scheduleGateReprobe(): void { - this.scheduleReprobeTick(this.gateReprobeDelayMs(), true) + this.scheduleReprobeTick(this.delays.gateReprobeDelayMs(this.gateReprobeStreak), true) } private scheduleReprobeTick(delay: number, mintToken: boolean): void { @@ -337,14 +342,6 @@ export class RelayReconnectController { }, delay) } - private gateReprobeDelayMs(): number { - const base = Math.min( - RELAY_GATE_REPROBE_CEILING_MS, - RELAY_GATE_REPROBE_BASE_MS * 2 ** Math.min(this.gateReprobeStreak, 8) - ) - return Math.floor(base * (0.75 + 0.5 * this.jitterFraction())) - } - // Why: clearing a gate must also drop its timer, pending tick, and cadence — // an orphaned reprobe timer would otherwise swallow the next fast backoff. private liftGate(): void { @@ -353,21 +350,4 @@ export class RelayReconnectController { this.gateReprobeStreak = 0 this.clearTimer() } - - private delayMs(): number { - const exponent = Math.max(0, this.consecutiveFailures - 1) - const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent) - // Full jitter (uniform in [0, cap)), floored so retries never busy-loop. - return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction())) - } - - private hostOfflineDelayMs(): number { - const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS - return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction()) - } - - private jitterFraction(): number { - const [high, low] = this.dependencies.randomBytes(2) - return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00 - } } diff --git a/mobile/src/transport/mobile-relay-retry-delays.ts b/mobile/src/transport/mobile-relay-retry-delays.ts new file mode 100644 index 00000000000..6ff145acdb4 --- /dev/null +++ b/mobile/src/transport/mobile-relay-retry-delays.ts @@ -0,0 +1,45 @@ +// Why: relay resume closes and silent cellular NAT rebinds otherwise cause +// immediate re-dials that ping-pong the phone between connected and disconnected. +const RELAY_BACKOFF_MIN_MS = 250 +const RELAY_BACKOFF_BASE_MS = 500 +const RELAY_BACKOFF_CEILING_MS = 30_000 +export const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS +const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000 +const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 +// Why: gates must slow recovery down, never end it — a relay-only phone has no +// direct path to refresh credentials, so a timerless gate is a permanent outage. +// The cadence escalates and jitters so a permanently revoked device is not a +// forever one-minute beacon and a fleet-wide gating event cannot phase-align. +const RELAY_GATE_REPROBE_BASE_MS = 60_000 +const RELAY_GATE_REPROBE_CEILING_MS = 15 * 60_000 + +// Every relay retry delay in one place: transport backoff, known-offline +// polling, and the escalating gated reprobe cadence, all jittered. +export class RelayRetryDelays { + constructor(private readonly randomBytes: (length: number) => Uint8Array) {} + + transportDelayMs(consecutiveFailures: number): number { + const exponent = Math.max(0, consecutiveFailures - 1) + const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent) + // Full jitter (uniform in [0, cap)), floored so retries never busy-loop. + return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction())) + } + + hostOfflineDelayMs(): number { + const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS + return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction()) + } + + gateReprobeDelayMs(streak: number): number { + const base = Math.min( + RELAY_GATE_REPROBE_CEILING_MS, + RELAY_GATE_REPROBE_BASE_MS * 2 ** Math.min(streak, 8) + ) + return Math.floor(base * (0.75 + 0.5 * this.jitterFraction())) + } + + private jitterFraction(): number { + const [high, low] = this.randomBytes(2) + return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00 + } +} From 665b85047b2863e64ec3697f99c046966887dd5d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:23:43 -0700 Subject: [PATCH 040/101] fix(relay): revive a dead relay broker without user interaction (#12432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broker deaths that end with closeNow() — an auth refresh failing past token expiry (laptop sleep) or a transient context read at open — left no retry timer, so Relay stayed offline until the user clicked Retry or auth state changed. Adds a dead-man's switch: - RelayAuthCoordinator.ensureLive(): reconciles only when there is no live broker, no scheduled retry, and no open in flight - DesktopRelayService arms a 5-minute liveness interval and exposes ensureLive() for wake signals - powerMonitor 'resume' triggers ensureLive (sleep is the common cause) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/index.ts | 5 +- .../runtime/relay/desktop-relay-service.ts | 20 +++++ .../relay-auth-coordinator-recovery.test.ts | 74 +++++++++++++++++++ .../runtime/relay/relay-auth-coordinator.ts | 16 ++++ 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index 3521070b55c..39fc076be0a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,7 +2,7 @@ import { existsSync, statSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import os from 'node:os' -import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type Tray } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerMonitor, type Tray } from 'electron' import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' import { electronApp, is } from '@electron-toolkit/utils' import { @@ -2897,6 +2897,9 @@ void app.whenReady().then(async () => { provisionRelay: (context, params) => relayService.provisionRelay(context, params) }) relayService.start() + // Why: sleeping past relay-token expiry kills the broker with no retry + // timer; resume is the moment that state becomes recoverable. + powerMonitor.on('resume', () => desktopRelayService?.ensureLive()) } catch (error) { console.warn( '[relay] Desktop relay startup unavailable:', diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index c52d80a8fe4..04236fea2b7 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -42,12 +42,18 @@ export function pairingAuthorizationForContext( : null } +// Why: a broker that died without arming a retry (sleep past token expiry, +// transient auth read) must not stay dead until the user clicks Retry. The +// cadence is slow because it is a safety net, not the primary retry path. +const RELAY_LIVENESS_INTERVAL_MS = 5 * 60_000 + export class DesktopRelayService { private readonly coordinator: RelayAuthCoordinator private readonly revokeOutbox: RelayRevokeOutbox private readonly runtimeRpc: OrcaRuntimeRpcServer private readonly demandLedger: RelayDemandLedger private demandExpiryTimer: ReturnType | null = null + private livenessTimer: ReturnType | null = null private stopped = false constructor(options: DesktopRelayServiceOptions) { @@ -90,6 +96,16 @@ export class DesktopRelayService { start(): void { this.refreshDemand() + if (!this.livenessTimer) { + this.livenessTimer = setInterval(() => this.ensureLive(), RELAY_LIVENESS_INTERVAL_MS) + } + } + + // Safe to call from any wake signal (power resume, network change). + ensureLive(): void { + if (!this.stopped) { + this.coordinator.ensureLive() + } } authMutated(): void { @@ -217,6 +233,10 @@ export class DesktopRelayService { clearTimeout(this.demandExpiryTimer) this.demandExpiryTimer = null } + if (this.livenessTimer) { + clearInterval(this.livenessTimer) + this.livenessTimer = null + } this.coordinator.stop() } diff --git a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts index 29b1d3596da..8bfc71688e0 100644 --- a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts +++ b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts @@ -257,3 +257,77 @@ describe('RelayAuthCoordinator transient recovery', () => { expect(openBroker).toHaveBeenCalledTimes(2) }) }) + +describe('RelayAuthCoordinator liveness safety net', () => { + it('reopens a broker that died leaving no retry timer behind', async () => { + // Why: an auth refresh failing past token expiry closes the broker with + // no timer; only ensureLive (periodic/power-resume) can revive it. + vi.useFakeTimers() + const dead = { closeNow: vi.fn(), isLive: () => false } + const live = { closeNow: vi.fn(), isLive: () => true } + const openBroker = vi.fn().mockResolvedValueOnce(dead).mockResolvedValueOnce(live) + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker, + onStatus: vi.fn(), + random: () => 0.5 + }) + + coordinator.reconcile() + await vi.advanceTimersByTimeAsync(0) + expect(coordinator.getActiveBroker()).toBe(dead) + await vi.advanceTimersByTimeAsync(600_000) + expect(openBroker).toHaveBeenCalledOnce() + + coordinator.ensureLive() + await vi.advanceTimersByTimeAsync(0) + expect(openBroker).toHaveBeenCalledTimes(2) + expect(dead.closeNow).toHaveBeenCalled() + expect(coordinator.getActiveBroker()).toBe(live) + }) + + it('does not disturb a live broker, a scheduled retry, or an open in flight', async () => { + vi.useFakeTimers() + const broker = { closeNow: vi.fn(), isLive: () => true } + let releaseOpen: ((value: typeof broker) => void) | undefined + const openBroker = vi + .fn() + .mockRejectedValueOnce(new RelayHttpError('assignment', 503, 30_000)) + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseOpen = resolve + }) + ) + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker, + onStatus: vi.fn(), + random: () => 0.5 + }) + + coordinator.reconcile() + await vi.advanceTimersByTimeAsync(0) + expect(openBroker).toHaveBeenCalledOnce() + + // A scheduled Retry-After timer owns recovery; ensureLive must not race it. + coordinator.ensureLive() + await vi.advanceTimersByTimeAsync(0) + expect(openBroker).toHaveBeenCalledOnce() + + // The retry fires into a slow open; ensureLive must not preempt it. + await vi.advanceTimersByTimeAsync(30_000) + expect(openBroker).toHaveBeenCalledTimes(2) + coordinator.ensureLive() + await vi.advanceTimersByTimeAsync(0) + expect(openBroker).toHaveBeenCalledTimes(2) + releaseOpen?.(broker) + await vi.advanceTimersByTimeAsync(0) + expect(coordinator.getActiveBroker()).toBe(broker) + + // A live broker needs nothing. + coordinator.ensureLive() + await vi.advanceTimersByTimeAsync(0) + expect(openBroker).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/runtime/relay/relay-auth-coordinator.ts b/src/main/runtime/relay/relay-auth-coordinator.ts index a09c8f6374a..f6539842f8a 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.ts @@ -92,6 +92,22 @@ export class RelayAuthCoordinator { return this.ownership?.valid ? this.ownership.broker : null } + // Why: some broker deaths end with no retry timer — an auth refresh that + // fails past token expiry (laptop sleep), or a transient context read that + // returned null at open. Periodic/power-resume callers use this as a + // dead-man's switch; it never disturbs a live broker, a scheduled retry, + // or an open already in flight. + ensureLive(): void { + if (this.stopped || this.retryTimer || this.pendingOwnerships.size > 0) { + return + } + const ownership = this.ownership + if (ownership?.valid && (ownership.broker?.isLive?.() ?? true)) { + return + } + this.beginReconcile(false) + } + async waitForActiveBroker(): Promise { while (!this.stopped) { const broker = this.getActiveBroker() From 5a2b329d8d7e96c974aecf4d3e12b707abd7d732 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:24:25 -0700 Subject: [PATCH 041/101] chore(mobile): bump to 0.0.37 (versionCode 10) (#12365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the 0.0.37 release attempted on 2026-08-03 (run 30791649691 failed on the version assertion). Ships the post-0.0.36 transport fixes: relay session recovery when the LAN endpoint is unreachable (#12344, #11368, #11465, #11690) and honest worktree-catalog failure states (#12235) — the released-app defect class verified live tonight. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- mobile/app.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/app.json b/mobile/app.json index 413cebc8d28..53654335486 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.36", + "version": "0.0.37", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 9 + "versionCode": 10 }, "plugins": [ "expo-router", From 0f052a50100cb31751f65bff4e98ae795525c04d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:25:02 -0700 Subject: [PATCH 042/101] fix(editor): stop Cmd/Ctrl+Shift+T reopening a phantom editor tab (#12399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `openFile` honored a recently-closed snapshot's `reopenId` whenever no open file currently held that id, without checking whether the same (path, mode, owner) was already open under a different id. Editor ids are not symmetrical across worktrees: the first worktree to open an absolute path gets the bare path as its id, and any other owner of the same path gets a namespaced `editor:::` id. So a snapshot captured under one shape could be restored while the live tab carried the other. When that happened `openFile` took the `existing` branch and mapped over `openFiles` looking for the stale id, matching nothing — no OpenFile was created or updated — yet it still set `activeFileId`, the per-worktree active id, the tab bar order and a unified tab entity to that stale id. The user got a tab rendering nothing and a dangling active editor. Resolve the id from the already-open file whenever one exists, so capture and restore agree on canonicalization. This is a no-op for the non-reopen path, where `resolveEditorFileIdForOwner` already returns `existing.id` via the identical predicate. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/renderer/src/store/slices/editor.test.ts | 43 ++++++++++++++++++++ src/renderer/src/store/slices/editor.ts | 6 ++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 1f2c8474641..a3c572612a4 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -1716,6 +1716,49 @@ describe('createEditorSlice recently closed editor tabs', () => { expect(store.getState().openFiles[0]?.id).toBe(firstId) expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([firstId]) }) + + it('reactivates the live tab when a stale reopen id targets an already-open file', () => { + const store = createEditorTabsStore() + store.setState({ + worktreesByRepo: { + 'repo-1': [ + { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + { id: 'wt-2', repoId: 'repo-1', path: '/repo-2' } + ] + } + } as unknown as Partial) + const sharedPath = '/home/me/.zshrc' + const openShared = (worktreeId: string): string => + store.getState().openFile({ + filePath: sharedPath, + relativePath: '.zshrc', + worktreeId, + language: 'shell', + mode: 'edit' + }) + + // Bare path id: nothing else owns this path yet. + const staleWt1Id = openShared('wt-1') + expect(staleWt1Id).toBe(sharedPath) + store.getState().closeFile(staleWt1Id) + + // wt-2 claims the bare id, so wt-1's reopen gets a namespaced id instead. + const wt2Id = openShared('wt-2') + expect(wt2Id).toBe(sharedPath) + const liveWt1Id = openShared('wt-1') + expect(liveWt1Id).toBe(ownedEditorFileId(sharedPath, 'wt-1', null)) + store.getState().closeFile(wt2Id) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().openFiles.map((file) => file.id)).toEqual([liveWt1Id]) + expect(store.getState().activeFileId).toBe(liveWt1Id) + expect(store.getState().activeFileIdByWorktree['wt-1']).toBe(liveWt1Id) + expect( + (store.getState().unifiedTabsByWorktree['wt-1'] ?? []).map((tab) => tab.entityId) + ).toEqual([liveWt1Id]) + expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([liveWt1Id]) + }) }) describe('createEditorSlice markdown view state', () => { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index b8d5faa39dd..be768183cac 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -1743,8 +1743,10 @@ export const createEditorSlice: StateCreator = (s matchesEditorMode(f, reusableOpenFileModes) && isSameEditorOwner(f, worktreeId, runtimeEnvironmentId) ) - const id = - options?.reopenId && !s.openFiles.some((candidate) => candidate.id === options.reopenId) + // Why: a snapshot's reopenId can be a stale shape — the same path is bare in whichever worktree opened it first and namespaced elsewhere — so honoring it while this owner's tab is already open would strand activeFileId and the unified tab on an id no OpenFile has. + const id = existing + ? existing.id + : options?.reopenId && !s.openFiles.some((candidate) => candidate.id === options.reopenId) ? options.reopenId : resolveEditorFileIdForOwner( s, From bdafe840f7493321284425a5d613332524954f03 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:25:08 -0700 Subject: [PATCH 043/101] fix(browser): keep the chrome inset when a page viewport shell is rebuilt (#12401) The chrome inset that pushes a browser guest below the toolbar was applied only from BrowserPane's layout effect, whose deps cover the first mount (page id, active, paintable, slot readiness). Every path that rebuilds the viewport shell without re-running that effect therefore produced a shell with a zero-height inset: guest recovery replacing a lost guest (replacePersistentWebview -> removeBrowserPageViewport -> render-time ensureBrowserPageViewport), a session-profile switch that recreates the guest for a new partition, and an overlay slot-root remount (STA-3228). The recovered page then painted underneath the browser chrome until an unrelated resize or tab switch forced a recompute. Make the geometry a property of the viewport instead of the mount: remember the last synced inset height per page and apply it whenever a shell is built. This also fixes the ordering case where the chrome is measured before the shell exists (the height used to be dropped on the floor). Test: browser-page-viewport.test.ts covers the rebuild-after-replacement and measured-before-shell cases; both fail without the change. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../browser-page-viewport.test.ts | 21 +++++++++++++++++++ .../browser-pane/browser-page-viewport.ts | 13 +++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts b/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts index 8ea69a07c8c..d6def5c80e5 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts +++ b/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts @@ -121,6 +121,27 @@ describe('syncBrowserPageChromeInset', () => { const viewport = ensureBrowserPageViewport('page-1', 'workspace-1')! expect(viewport.chromeInset.style.height).toBe('48px') }) + + it('restores the inset when guest recovery rebuilds the shell', () => { + mountSlotViewport('workspace-1') + ensureBrowserPageViewport('page-1', 'workspace-1') + syncBrowserPageChromeInset('page-1', 48) + // Guest replacement tears the shell down; the recovery re-render rebuilds it without re-measuring the chrome. + removeBrowserPageViewport('page-1') + + const rebuilt = ensureBrowserPageViewport('page-1', 'workspace-1')! + + expect(rebuilt.chromeInset.style.height).toBe('48px') + }) + + it('applies an inset measured before the shell existed', () => { + syncBrowserPageChromeInset('page-2', 40) + mountSlotViewport('workspace-1') + + const viewport = ensureBrowserPageViewport('page-2', 'workspace-1')! + + expect(viewport.chromeInset.style.height).toBe('40px') + }) }) describe('applyBrowserPageViewportLayout', () => { diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.ts b/src/renderer/src/components/browser-pane/browser-page-viewport.ts index d34fbf744f4..e4a2441c755 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.ts +++ b/src/renderer/src/components/browser-pane/browser-page-viewport.ts @@ -14,6 +14,11 @@ type BrowserPageViewport = { const browserPageViewports = new Map() +// Why: React measures the chrome only on mount, but shells are rebuilt without a +// re-measure (guest recovery/replacement, profile switch, slot remount). Remembering +// the inset keeps geometry a property of attaching a guest, not of the first mount. +const browserPageChromeInsetHeights = new Map() + const slotRootListeners = new Map void>>() function notifySlotRootListeners(workspaceTabId: string): void { @@ -88,6 +93,10 @@ export function ensureBrowserPageViewport( const chromeInset = document.createElement('div') chromeInset.dataset.browserPageChromeInset = '' chromeInset.className = 'shrink-0' + const rememberedInsetHeight = browserPageChromeInsetHeights.get(browserPageId) + if (rememberedInsetHeight !== undefined) { + chromeInset.style.height = `${rememberedInsetHeight}px` + } const container = document.createElement('div') container.dataset.browserPageContainer = '' @@ -141,11 +150,13 @@ export function applyBrowserPageViewportLayout( } export function syncBrowserPageChromeInset(browserPageId: string, heightPx: number): void { + const insetHeight = Math.max(0, heightPx) + browserPageChromeInsetHeights.set(browserPageId, insetHeight) const viewport = browserPageViewports.get(browserPageId) if (!viewport) { return } - viewport.chromeInset.style.height = `${Math.max(0, heightPx)}px` + viewport.chromeInset.style.height = `${insetHeight}px` } export function parkBrowserPageViewport(browserPageId: string): void { From 1ef83986a131e544294b14a6c47a969594a5b17d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:25:14 -0700 Subject: [PATCH 044/101] fix(codex): stop a surviving descendant wedging the Codex home process lock forever (#12410) * fix(codex): stop a surviving descendant wedging the Codex home process lock The per-Codex-home process lock was released only by the child's 'close' event. 'close' fires when the child AND every process that inherited its stdio have exited, so a grandchild that outlives codex (an MCP server, a helper, an `sh -c` wrapper) keeps the pipe open and 'close' never arrives. killProcessTree only SIGKILLs the direct child on POSIX, so Stop and the 60s generation timeout both leave the lock held. Every later AI commit message, PR field, branch name, and quota probe for that home then queued forever with no error and no recovery until the app restarted. Release the hold on 'exit' as well as 'close': once the codex process itself is gone it can no longer rotate that home's auth.json, which is the race the lock exists to prevent, so waiting on descendants bought nothing. As a backstop for a child that never reports exit at all (a kill that does not land), bound the hold: while another run is queued behind it, a hold that outlives CODEX_HOME_PROCESS_LOCK_MAX_HOLD_MS releases the queue. The cap is armed only when someone is waiting and only counts time the entry actually holds the lock, so uncontended runs arm no timer and queueing behind a slow predecessor never shortens a run's own budget. * fix(codex): preserve home lock exclusivity --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../codex-cli/codex-home-process-lock.test.ts | 26 +++++++- .../commit-message-text-generation.test.ts | 65 +++++++++++++++++++ .../commit-message-text-generation.ts | 18 +++-- 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/main/codex-cli/codex-home-process-lock.test.ts b/src/main/codex-cli/codex-home-process-lock.test.ts index 7287d011b42..e5a7eee7976 100644 --- a/src/main/codex-cli/codex-home-process-lock.test.ts +++ b/src/main/codex-cli/codex-home-process-lock.test.ts @@ -1,6 +1,6 @@ import { join } from 'node:path' import { homedir } from 'node:os' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveCodexHomeProcessLockKey, resolveCodexHomeProcessLockKeyForSpawnEnv, @@ -70,6 +70,30 @@ describe('withCodexHomeProcessLock', () => { await expect(withCodexHomeProcessLock('home-a', async () => 'after')).resolves.toBe('after') }) + it('does not release a running lock based on elapsed time', async () => { + vi.useFakeTimers() + try { + const events: string[] = [] + const gate = deferred() + const first = withCodexHomeProcessLock('home-long-running', async () => { + events.push('first:start') + await gate.promise + }) + const second = withCodexHomeProcessLock('home-long-running', async () => { + events.push('second:start') + }) + + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(events).toEqual(['first:start']) + + gate.resolve() + await Promise.all([first, second]) + expect(events).toEqual(['first:start', 'second:start']) + } finally { + vi.useRealTimers() + } + }) + it('keys explicit and default host homes consistently', () => { const previousCodexHome = process.env.CODEX_HOME delete process.env.CODEX_HOME diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 5c21ffa231e..3d435ee9477 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -540,6 +540,38 @@ describe('discoverCommitMessageModelsLocal', () => { } }) + it('releases the Codex home after a discovery timeout once the child exits', async () => { + vi.useFakeTimers() + const firstChild = createMockDiscoveryChild() + const secondChild = createMockDiscoveryChild() + spawnMock.mockReturnValueOnce(firstChild as never).mockReturnValueOnce(secondChild as never) + const env = { CODEX_HOME: '/managed/codex-discovery-descendant-home' } + + try { + const first = discoverCommitMessageModelsLocal('codex', env) + await vi.advanceTimersByTimeAsync(0) + const second = discoverCommitMessageModelsLocal('codex', env) + await vi.advanceTimersByTimeAsync(60_000) + await expect(first).resolves.toMatchObject({ success: false }) + expect(spawnMock).toHaveBeenCalledTimes(1) + + // A grandchild kept the inherited stdout open, so the killed child reports + // 'exit' and 'close' never arrives. + firstChild.emit('exit', null, 'SIGKILL') + await vi.advanceTimersByTimeAsync(0) + expect(spawnMock).toHaveBeenCalledTimes(2) + + secondChild.stdout.emit( + 'data', + Buffer.from(JSON.stringify({ models: [{ slug: 'gpt-5.5', display_name: 'GPT-5.5' }] })) + ) + secondChild.emit('close', 0) + await expect(second).resolves.toMatchObject({ success: true, defaultModelId: 'gpt-5.5' }) + } finally { + vi.useRealTimers() + } + }) + it('settles and detaches model discovery when output exceeds the limit', async () => { const child = createMockDiscoveryChild() spawnMock.mockReturnValue(child as never) @@ -1692,6 +1724,39 @@ describe('generateCommitMessageFromContext', () => { await expect(second).resolves.toMatchObject({ success: true, message: 'Update README' }) }) + it('releases the Codex home lock when the child exits with a descendant holding its stdio', async () => { + const firstChild = createMockDiscoveryChild() + const secondChild = createMockDiscoveryChild() + spawnMock.mockReturnValueOnce(firstChild as never).mockReturnValueOnce(secondChild as never) + const env = { CODEX_HOME: '/managed/codex-descendant-home' } + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + const params = { agentId: 'codex' as const, model: 'gpt-5.5' } + + const first = generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: '/descendant-repo', + env + }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + cancelGenerateCommitMessageLocal('/descendant-repo') + await expect(first).resolves.toMatchObject({ canceled: true }) + expectChildTerminated(firstChild) + + // SIGKILL reaches the codex process but not a grandchild that inherited its + // stdout, so 'exit' arrives and 'close' never does. + firstChild.emit('exit', null, 'SIGKILL') + + const second = generateCommitMessageFromContext(context, params, { + kind: 'local', + cwd: '/descendant-repo-2', + env + }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + secondChild.stdout.emit('data', Buffer.from('Update README\n')) + secondChild.emit('close', 0) + await expect(second).resolves.toMatchObject({ success: true, message: 'Update README' }) + }) + it('holds the Codex home lock until Windows tree termination and wrapper close', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 8410231dc40..98314f6975b 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -452,6 +452,10 @@ export async function discoverCommitMessageModelsLocal( if (agentId === 'codex') { // Result publication stays prompt while the home lock follows the // process lifetime after asynchronous timeout/output-limit kills. + // Why: 'close' also waits on descendants that inherited this child's + // stdio, so a surviving MCP helper would hold the home forever; at + // 'exit' the codex process is gone and can no longer rotate auth.json. + child.once('exit', markClosedAfterTermination) child.once('close', markClosedAfterTermination) } child.on('error', onError) @@ -604,7 +608,7 @@ function runLocalPlan( emptyResultName = 'message', operation: TextGenerationOperation = 'commit-message', wslDistro?: string, - holdHomeLockUntilClose = false + holdHomeLockUntilExit = false ): LocalProcessExecution { const { binary, args, stdinPayload, label } = plan let markProcessClosed!: () => void @@ -685,7 +689,7 @@ function runLocalPlan( if (cancelToken && cancelTokensByLane.get(laneKey) === cancelToken) { cancelTokensByLane.delete(laneKey) } - if (!holdHomeLockUntilClose) { + if (!holdHomeLockUntilExit) { markProcessClosed() } resolve(result) @@ -769,7 +773,11 @@ function runLocalPlan( } child.stdout?.on('data', onStdoutData) child.stderr?.on('data', onStderrData) - if (holdHomeLockUntilClose) { + if (holdHomeLockUntilExit) { + // Why: 'close' also waits on descendants that inherited this child's + // stdio, so a surviving MCP helper would hold the home forever; at 'exit' + // the codex process is gone and can no longer rotate auth.json. + child.once('exit', markClosedAfterTermination) child.once('close', markClosedAfterTermination) } child.on('error', onError) @@ -801,7 +809,7 @@ function runLocalPlanForAgent( operation: TextGenerationOperation ): Promise { const start = ( - holdHomeLockUntilClose = false + holdHomeLockUntilExit = false ): LocalProcessExecution => runLocalPlan( plan, @@ -810,7 +818,7 @@ function runLocalPlanForAgent( emptyResultName, operation, target.wslDistro, - holdHomeLockUntilClose + holdHomeLockUntilExit ) if (agentId !== 'codex') { // Why: no extra promise hops here — cancellation timing for non-codex From 20a2901677a9aec7f069b93f94ac6420e058ab74 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:25:21 -0700 Subject: [PATCH 045/101] fix(worktree): tell the truth about live PTYs, and offer force for a wedged sweep (#12394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worktree): tell the truth about live PTYs, and offer force for a wedged sweep Two gaps in the #11960 force path: The delete toast described every unstopped-PTY failure as "could not confirm every terminal has exited", including the case where verification positively watched them running. Force Delete proceeds either way, so the user was being asked to waive a doubt that did not exist while a running agent's uncommitted work died with it. The live verdict now gets copy that says so. A sweep that rejects before any per-PTY verdict exists (wedged daemon, dropped SSH channel) fails with a teardown-timeout message that the force classifier did not recognise, so no Force Delete button appeared — the exact dead end #11960 set out to remove. That error now carries the shared prefix and classifies. * fix(worktree): close the sweep-rejection wedge and stop racing the delete Review of #12394 found the fix covered only half the wedge it named, and routed users into a force path whose own safety comment was untrue. 1. Only the outer deadline was classifiable. When a provider *rejects* the sweep — dropped SSH channel, erroring daemon — settleBeforeDeadline rejects with the provider's original error, which carries no marker, so classifyWorktreeForceDeleteReason still returned null and no Force Delete button rendered. That is the exact case #11960 named. A rejected sweep on the destructive path is now reworded through the existing unstopped-PTY prefix (provider text preserved, original kept as `cause`), so old and new clients alike classify it as 'unstopped-pty'. 2. Force could delete files while a sweep was still running. The deadline rejects without cancelling run(), so allSettled resolved with shutdown() still in flight — by construction the deadline error can only fire while something is in flight. Force then deleted the directory a live PTY still held open (EBUSY / half-delete on Windows and WSL). Sweeps are now tracked so the forced path waits for the abandoned work, bounded by a 2s grace; force never wedges, and when the grace expires the warning says handles may outlive the delete instead of implying the sweep finished. 3. The toast test named for the classifier passed the reason in as a literal, so it never exercised it. It now derives the reason exactly as the store does, and fails against main. 4. Added the missing unstoppedPtyLive key to the English catalog. 5. isProvenLivePtyRemovalError anchored the 'still live:' marker to the detail separator, so a worktree path can no longer spell out a live verdict and flip the toast to the destructive copy. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/runtime/forced-sweep-settlement.ts | 122 ++++++++++++++++++ .../runtime/unstopped-pty-verification.ts | 22 +++- .../worktree-teardown-unstopped-pty.test.ts | 85 ++++++++++++ src/main/runtime/worktree-teardown.ts | 97 +++++++------- .../sidebar/delete-worktree-toast.test.ts | 59 ++++++++- .../sidebar/delete-worktree-toast.ts | 17 ++- src/renderer/src/i18n/locales/en.json | 3 +- ...ktree-removal-force-classification.test.ts | 42 +++++- src/shared/worktree-removal.ts | 28 +++- 9 files changed, 410 insertions(+), 65 deletions(-) create mode 100644 src/main/runtime/forced-sweep-settlement.ts diff --git a/src/main/runtime/forced-sweep-settlement.ts b/src/main/runtime/forced-sweep-settlement.ts new file mode 100644 index 00000000000..e53a0e66ce0 --- /dev/null +++ b/src/main/runtime/forced-sweep-settlement.ts @@ -0,0 +1,122 @@ +import { describeError } from './unstopped-pty-verification' + +// Why: an abandoned sweep is one whose provider ignored the deadline it was already given, +// so this is a second chance, not a second budget — long enough for a shutdown() that is +// mid-kill to finish releasing handles, short enough that Force Delete still feels forced. +export const ABANDONED_SWEEP_GRACE_MS = 2_000 + +export type WorktreeSweepTracker = { + /** Wraps a sweep body so the promise stays observable after its deadline abandons it. */ + track: (run: () => Promise) => () => Promise + /** Resolves when every tracked sweep settled, or the grace expired; true if one is still running. */ + awaitAbandonedSweeps: (graceMs?: number) => Promise +} + +/** + * Keeps the real sweep promises reachable after `settleBeforeDeadline` stops waiting on them. + * + * Why: that helper rejects on its timer without cancelling `run()`, so by construction the + * deadline error can only fire while work is still in flight — and forced removal deletes + * the worktree directory the moment teardown returns. + */ +export function createWorktreeSweepTracker(): WorktreeSweepTracker { + const running = new Set>() + return { + track: + (run: () => Promise) => + () => { + const sweep = run() + running.add(sweep) + // Why: the tracked copy is observed here too, so a rejection this call abandons + // never surfaces as an unhandled rejection in whatever ran next. + void sweep.catch(() => undefined).finally(() => running.delete(sweep)) + return sweep + }, + awaitAbandonedSweeps: async (graceMs = ABANDONED_SWEEP_GRACE_MS) => { + const pending = [...running] + if (pending.length === 0) { + return false + } + let expiry: ReturnType | undefined + const expired = new Promise((resolve) => { + expiry = setTimeout(() => resolve(true), graceMs) + expiry.unref?.() + }) + const stillRunning = await Promise.race([ + Promise.allSettled(pending).then(() => false), + expired + ]) + clearTimeout(expiry) + return stillRunning + } + } +} + +export type WorktreeSweepPromises = { + runtime: Promise<{ stopped: number }> + provider: Promise + registry: Promise +} + +export type ForcedSweepSettlement = { + stopped: { runtimeStopped: number; providerStopped: number; registryStopped: number } + /** A sweep failed outright, so no per-PTY verdict below it can be trusted. */ + incomplete: boolean +} + +/** + * Reconciles the three sweeps for a removal carrying the Force Delete waiver (#11960). + * + * Force goes on to delete files, so every sweep must finish releasing handles first — + * `Promise.all` would abandon the siblings of the first rejection while they were still + * inside shutdown(). Waiting on the tracked promises rather than only on their deadline + * wrappers is what makes that true for the timeout case as well. + */ +export async function settleSweepsForForcedRemoval( + worktreeId: string, + sweeps: WorktreeSweepPromises, + tracker: WorktreeSweepTracker, + deadlineError: Error +): Promise { + const settled = await Promise.allSettled([sweeps.runtime, sweeps.provider, sweeps.registry]) + const [runtimeSettled, providerSettled, registrySettled] = settled + // Report what the surviving sweeps actually stopped rather than a flat zero. + const stopped = { + runtimeStopped: runtimeSettled.status === 'fulfilled' ? runtimeSettled.value.stopped : 0, + providerStopped: providerSettled.status === 'fulfilled' ? providerSettled.value : 0, + registryStopped: registrySettled.status === 'fulfilled' ? registrySettled.value : 0 + } + const reasons = settled.flatMap((result) => + result.status === 'rejected' ? [result.reason as unknown] : [] + ) + const stillRunning = await tracker.awaitAbandonedSweeps() + if (reasons.length === 0 && !stillRunning) { + return { stopped, incomplete: false } + } + // Why (#11960): a sweep that cannot even complete — unresponsive daemon, dropped SSH + // channel — fails before the unproven-stop gate could offer its escape hatch, so an + // explicit Force Delete has to survive it. Report every reason, specific ones first: the + // shared deadline sentinel only says that *something* timed out, so leading with it would + // bury the provider error that actually explains the failure. This warning is the only + // trace of a removal that deleted files without proving a single stop. + const detail = [ + ...reasons.filter((candidate) => candidate !== deadlineError), + ...reasons.filter((candidate) => candidate === deadlineError) + ].map(describeError) + if (stillRunning) { + // Force must never wedge, so the wait is bounded — but "we stopped waiting" is not + // "handles released", and on Windows/WSL that is the difference between a clean + // removal and a half-deleted directory. Say so instead of implying the sweep finished. + detail.push( + `a sweep was still running after the ${ABANDONED_SWEEP_GRACE_MS}ms grace, so its PTY handles may outlive the delete` + ) + } + console.warn( + `[worktree-teardown] forcing removal after an incomplete PTY sweep for ${worktreeId} — ${detail.join('; ')}` + ) + // Reporting `incomplete` does skip the per-PTY verdict, which could still have named live + // PTYs when only one sweep failed — accepted for now because this path is behind an + // explicit Force Delete, deletes either way, and clears no registry rows, so the cost is + // diagnosability rather than safety. + return { stopped, incomplete: reasons.length > 0 } +} diff --git a/src/main/runtime/unstopped-pty-verification.ts b/src/main/runtime/unstopped-pty-verification.ts index b0df601f133..068334eb98d 100644 --- a/src/main/runtime/unstopped-pty-verification.ts +++ b/src/main/runtime/unstopped-pty-verification.ts @@ -1,5 +1,9 @@ import type { IPtyProvider } from '../providers/types' -import { UNSTOPPED_PTY_REMOVAL_PREFIX } from '../../shared/worktree-removal' +import { + UNSTOPPED_PTY_DETAIL_SEPARATOR, + UNSTOPPED_PTY_LIVE_DETAIL_PREFIX, + UNSTOPPED_PTY_REMOVAL_PREFIX +} from '../../shared/worktree-removal' import { settleBeforeDeadline } from './settle-before-deadline' // Floor for the verification window when the sweep ran on a very short budget. @@ -60,9 +64,21 @@ export function describeUnstoppedPtys( ): string { const detail = verdict.status === 'live' - ? `still live: ${verdict.ptyIds.join(', ')}` + ? `${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX} ${verdict.ptyIds.join(', ')}` : `could not verify these exited: ${failedPtyIds.join(', ')} (${verdict.reason})` - return `${UNSTOPPED_PTY_REMOVAL_PREFIX} ${worktreeId} — ${detail}` + return `${UNSTOPPED_PTY_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${detail}` +} + +/** + * Words a sweep that never produced a per-PTY verdict — a wedged daemon, a dropped SSH + * channel — as the unstopped-PTY failure it is. + * + * Why (#11960): this rejection carried only the provider's own wording, which the force + * classifier cannot recognise, so the wedge the escape hatch exists for was the one case + * that never got offered it. The provider's message stays in the text; only the shape changes. + */ +export function describeFailedPtySweep(worktreeId: string, error: unknown): string { + return `${UNSTOPPED_PTY_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}the terminal sweep failed: ${describeError(error)}` } export function describeError(error: unknown): string { diff --git a/src/main/runtime/worktree-teardown-unstopped-pty.test.ts b/src/main/runtime/worktree-teardown-unstopped-pty.test.ts index 47852b45985..ca09e01b2d8 100644 --- a/src/main/runtime/worktree-teardown-unstopped-pty.test.ts +++ b/src/main/runtime/worktree-teardown-unstopped-pty.test.ts @@ -9,6 +9,11 @@ vi.mock('../memory/pty-registry', () => ({ })) import { killAllProcessesForWorktree, WORKTREE_PROCESS_SWEEP_TIMEOUT_MS } from './worktree-teardown' +import { ABANDONED_SWEEP_GRACE_MS } from './forced-sweep-settlement' +import { + classifyWorktreeForceDeleteReason, + isProvenLivePtyRemovalError +} from '../../shared/worktree-removal' import type { IPtyProvider, PtyProcessInfo } from '../providers/types' // Why: these tests advance fake timers *before* awaiting the teardown, so a @@ -243,6 +248,13 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { providerStopped: 0, registryStopped: 0 }) + // Why: the deadline gave up on this sweep without cancelling it, so the grace + // expired with the provider still inside listProcesses. Force deletes the + // directory anyway, and this warning is the only record that a PTY handle may + // have outlived it — a silent "incomplete sweep" reads as if it had finished. + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`still running after the ${ABANDONED_SWEEP_GRACE_MS}ms grace`) + ) } finally { releaseList([]) await vi.advanceTimersByTimeAsync(0) @@ -337,6 +349,79 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { ).rejects.toThrow('ssh channel closed') }) + // Why (#11960): the wedge the escape hatch exists for — an unresponsive daemon, a dropped + // SSH channel — rejects the sweep in the provider's own words, which the force classifier + // cannot recognise. Failing closed with no Force Delete button is the dead end itself. + it('offers force delete for a sweep-level failure without losing the provider wording', async () => { + listRegisteredPtysMock.mockReturnValue([]) + const localProvider = createProviderStub(async () => { + throw new Error('SSH channel closed while listing processes') + }) + + const error = await killAllProcessesForWorktree('repo-1::/w', { + localProvider, + requirePhysicalStop: true + }).then( + () => new Error('expected a rejection'), + (rejection: Error) => rejection + ) + expect(error.message).toContain('SSH channel closed while listing processes') + expect(classifyWorktreeForceDeleteReason(error.message)).toBe('unstopped-pty') + // Nothing was verified here, so it must not read as the proven-live verdict. + expect(isProvenLivePtyRemovalError(error.message)).toBe(false) + }) + + // Why: the outer deadline rejects without cancelling the sweep it gave up on, so force + // could return — and the caller start deleting the directory — while shutdown() was still + // killing the PTY holding it open. On Windows that rmdir fails and half-deletes the + // workspace, which is exactly what the ordering at the removal call site exists to avoid. + it('waits for a shutdown the deadline abandoned before force returns', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let releaseShutdown: () => void = () => {} + try { + listRegisteredPtysMock.mockReturnValue([]) + const localProvider = createProviderStub(async () => [ + { id: 'w1@@live-1', cwd: '/tmp/w1', title: 'shell' } + ]) + let shutdownFinished = false + ;(localProvider.shutdown as unknown as ReturnType).mockImplementation( + async () => + await new Promise((resolve) => { + releaseShutdown = () => { + shutdownFinished = true + resolve() + } + }) + ) + + const teardown = settleTeardown( + killAllProcessesForWorktree('w1', { + localProvider, + timeoutMs: 30, + requirePhysicalStop: true, + allowUnverifiedStop: true + }) + ) + let returned = false + void teardown.then(() => { + returned = true + }) + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(shutdownFinished).toBe(false) + expect(returned).toBe(false) + releaseShutdown() + await expect(teardown).resolves.toEqual({ + runtimeStopped: 0, + providerStopped: 0, + registryStopped: 0 + }) + } finally { + releaseShutdown() + warn.mockRestore() + } + }) + it('lets an explicit force removal proceed past PTYs it could not stop', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 45929e21f98..993a263d348 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -4,10 +4,16 @@ import { listRegisteredPtys } from '../memory/pty-registry' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { mapWithConcurrency } from '../../shared/map-with-concurrency' -import { WORKTREE_TEARDOWN_FORCE_HINT } from '../../shared/worktree-removal' +import { + isUnstoppedPtyRemovalError, + WORKTREE_TEARDOWN_FORCE_HINT, + WORKTREE_TEARDOWN_TIMEOUT_PREFIX +} from '../../shared/worktree-removal' import { settleBeforeDeadline } from './settle-before-deadline' +import { createWorktreeSweepTracker, settleSweepsForForcedRemoval } from './forced-sweep-settlement' import { describeError, + describeFailedPtySweep, describeUnstoppedPtys, verifyUnstoppedPtys, type UnstoppedPtyVerdict @@ -87,7 +93,10 @@ export async function killAllProcessesForWorktree( ): Promise { const sweepBudgetMs = Math.max(1, deps.timeoutMs ?? WORKTREE_PROCESS_SWEEP_TIMEOUT_MS) const deadline = Date.now() + sweepBudgetMs - const deadlineError = new Error(`Timed out waiting for physical PTY teardown: ${worktreeId}`) + const deadlineError = new Error( + `${WORKTREE_TEARDOWN_TIMEOUT_PREFIX} ${worktreeId}. ${WORKTREE_TEARDOWN_FORCE_HINT}` + ) + const sweeps = createWorktreeSweepTracker() const stopAttempts = new Map>() const stopPty = ( ptyId: string, @@ -116,7 +125,7 @@ export async function killAllProcessesForWorktree( // destructive removal closed. const runtimeSweep = deps.runtime ? settleBeforeDeadline( - () => + sweeps.track(() => deps.runtime!.stopTerminalsForWorktree(worktreeId, { deadline, stopPty, @@ -127,7 +136,8 @@ export async function killAllProcessesForWorktree( ...(deps.resolvedRuntimeEnvironmentId ? { resolvedRuntimeEnvironmentId: deps.resolvedRuntimeEnvironmentId } : {}) - }), + }) + ), { stopped: 0 }, deadline, deps.requirePhysicalStop ? deadlineError : undefined, @@ -142,7 +152,7 @@ export async function killAllProcessesForWorktree( deps.includeProviderInventory === false ? Promise.resolve(0) : settleBeforeDeadline( - () => + sweeps.track(() => sweepProviderByPrefix( worktreeId, deps.localProvider, @@ -150,7 +160,8 @@ export async function killAllProcessesForWorktree( stopPty, deps.onPtyStopped, deps.requirePhysicalStop - ), + ) + ), 0, deadline, deps.requirePhysicalStop ? deadlineError : undefined @@ -159,14 +170,15 @@ export async function killAllProcessesForWorktree( deps.includeLocalRegistry === false ? Promise.resolve(0) : settleBeforeDeadline( - () => + sweeps.track(() => sweepRegistryForWorktree( worktreeId, deps.localProvider, deadline, stopPty, deps.onPtyStopped - ), + ) + ), 0, deadline, deps.requirePhysicalStop ? deadlineError : undefined @@ -180,54 +192,39 @@ export async function killAllProcessesForWorktree( let providerStopped: number let registryStopped: number if (deps.allowUnverifiedStop) { - // Why: force goes on to delete files, so every sweep must finish releasing - // handles first — Promise.all would abandon the siblings of the first - // rejection while they were still inside shutdown(), racing the delete. - const settled = await Promise.allSettled([runtimeSweep, providerSweep, registrySweep]) - const [runtimeSettled, providerSettled, registrySettled] = settled - const reasons = settled.flatMap((result) => - result.status === 'rejected' ? [result.reason as unknown] : [] + const forced = await settleSweepsForForcedRemoval( + worktreeId, + { runtime: runtimeSweep, provider: providerSweep, registry: registrySweep }, + sweeps, + deadlineError ) - if (reasons.length > 0) { - // Why (#11960): a sweep that cannot even complete — unresponsive daemon, - // dropped SSH channel — fails before the unproven-stop gate below could - // offer its escape hatch, so an explicit Force Delete has to survive it. - // Report every reason, specific ones first: the shared deadline sentinel - // only says that *something* timed out, so leading with it would bury the - // provider error that actually explains the failure. This warning is the - // only trace of a removal that deleted files without proving a single stop. - const detail = [ - ...reasons.filter((candidate) => candidate !== deadlineError), - ...reasons.filter((candidate) => candidate === deadlineError) - ] - .map(describeError) - .join('; ') - console.warn( - `[worktree-teardown] forcing removal after an incomplete PTY sweep for ${worktreeId} — ${detail}` - ) - // Returning here does skip the verdict below, which could still have named - // live PTYs when only one sweep failed — accepted for now because this path - // is behind an explicit Force Delete, deletes either way, and clears no - // registry rows, so the cost is diagnosability rather than safety. - // Report what the surviving sweeps actually stopped rather than a flat zero. - return { - runtimeStopped: runtimeSettled.status === 'fulfilled' ? runtimeSettled.value.stopped : 0, - providerStopped: providerSettled.status === 'fulfilled' ? providerSettled.value : 0, - registryStopped: registrySettled.status === 'fulfilled' ? registrySettled.value : 0 - } + if (forced.incomplete) { + return forced.stopped } - runtimeResult = (runtimeSettled as PromiseFulfilledResult<{ stopped: number }>).value - providerStopped = (providerSettled as PromiseFulfilledResult).value - registryStopped = (registrySettled as PromiseFulfilledResult).value + runtimeResult = { stopped: forced.stopped.runtimeStopped } + providerStopped = forced.stopped.providerStopped + registryStopped = forced.stopped.registryStopped } else { // Why: without the waiver a rejection aborts the removal and nothing is // deleted, so failing fast is safe — and keeps a dead host reporting // immediately instead of after the full sweep budget. - ;[runtimeResult, providerStopped, registryStopped] = await Promise.all([ - runtimeSweep, - providerSweep, - registrySweep - ]) + try { + ;[runtimeResult, providerStopped, registryStopped] = await Promise.all([ + runtimeSweep, + providerSweep, + registrySweep + ]) + } catch (error) { + // Why (#11960): this rejection is the provider's own wording, which the force + // classifier cannot recognise — so the wedge Force Delete exists for was the one + // failure that never offered it. Re-word it, keeping the original as the cause. + throw deps.requirePhysicalStop && !isUnstoppedPtyRemovalError(describeError(error)) + ? new Error( + `${describeFailedPtySweep(worktreeId, error)}. ${WORKTREE_TEARDOWN_FORCE_HINT}`, + { cause: error } + ) + : error + } } if (deps.requirePhysicalStop) { const stopResults = await Promise.all( diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts index 16bf7c4b954..acaa7d8f16f 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' +import { classifyWorktreeForceDeleteReason } from '../../../../shared/worktree-removal' + +// Why: production never hands this function a literal reason — the store derives it from +// classifyWorktreeForceDeleteReason (store/slices/worktrees.ts). Passing one in would let a +// message the classifier rejects still render the force copy, testing a UI that never runs. +function toastCopyForRemovalError(worktreeName: string, error: string): unknown { + return getDeleteWorktreeToastCopy(worktreeName, classifyWorktreeForceDeleteReason(error), error) +} describe('getDeleteWorktreeToastCopy', () => { it('uses direct guidance when force delete is available', () => { @@ -14,11 +22,58 @@ describe('getDeleteWorktreeToastCopy', () => { // toast has to actually offer it — a reason of null hides the button entirely. it('uses terminal-teardown guidance when a PTY stop could not be proven', () => { expect( - getDeleteWorktreeToastCopy( + toastCopyForRemovalError( + 'feature/foo', + 'Failed to physically stop every PTY for worktree: repo-1::/w — could not verify these exited: term_a (the process list timed out)' + ) + ).toEqual({ + title: 'Failed to delete workspace feature/foo', + description: + 'Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.', + isDestructive: false + }) + }) + + // Why: Force Delete proceeds on a proven-live PTY too, so the copy must not describe + // that as an unconfirmed exit — the user is killing a terminal Orca watched running. + it('names the running terminals when verification proved they are still live', () => { + expect( + toastCopyForRemovalError( 'feature/foo', - 'unstopped-pty', 'Failed to physically stop every PTY for worktree: repo-1::/w — still live: term_a' ) + ).toEqual({ + title: 'Failed to delete workspace feature/foo', + description: + 'This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold.', + isDestructive: false + }) + }) + + // Why: a sweep that never answered wedges removal the same way, and the waiver clears + // both — so it must reach the same force affordance instead of a dead end. + it('offers force delete when the teardown sweep itself timed out', () => { + expect( + toastCopyForRemovalError( + 'feature/foo', + 'Timed out waiting for physical PTY teardown: repo-1::/w. Retry with force delete (--force) to remove it anyway.' + ) + ).toEqual({ + title: 'Failed to delete workspace feature/foo', + description: + 'Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.', + isDestructive: false + }) + }) + + // Why: the same wedge worded by the provider instead of the deadline — a dropped SSH + // channel — must reach the same offer, or the escape hatch misses the case it was for. + it('offers force delete when the sweep failed rather than timed out', () => { + expect( + toastCopyForRemovalError( + 'feature/foo', + 'Failed to physically stop every PTY for worktree: repo-1::/w — the terminal sweep failed: SSH channel closed while listing processes. Retry with force delete (--force) to remove it anyway.' + ) ).toEqual({ title: 'Failed to delete workspace feature/foo', description: diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.ts index 22f7eb7f5ad..fac1c6ee06a 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.ts @@ -1,6 +1,7 @@ import { translate } from '@/i18n/i18n' import { isLockedWorktreeRemovalError, + isProvenLivePtyRemovalError, type WorktreeForceDeleteReason } from '../../../../shared/worktree-removal' export type DeleteWorktreeToastCopy = { @@ -58,10 +59,18 @@ export function getDeleteWorktreeToastCopy( 'Failed to delete workspace {{value0}}', { value0: worktreeName } ), - description: translate( - 'auto.components.sidebar.delete.worktree.toast.unstoppedPty', - 'Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.' - ), + // Why: Force Delete proceeds either way, so the copy must say which case this is. + // Telling a user "could not confirm" about terminals Orca watched stay alive asks + // them to waive a doubt that does not exist, and any running agent's work dies with it. + description: isProvenLivePtyRemovalError(error) + ? translate( + 'auto.components.sidebar.delete.worktree.toast.unstoppedPtyLive', + 'This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold.' + ) + : translate( + 'auto.components.sidebar.delete.worktree.toast.unstoppedPty', + 'Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.' + ), isDestructive: false } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 05082ae9ba8..643a4f326c9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4773,7 +4773,8 @@ "0899ebdb28": "Git already forgot this workspace, but its directory is still on disk. Use Force Delete to remove the orphaned directory.", "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion.", - "unstoppedPty": "Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway." + "unstoppedPty": "Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.", + "unstoppedPtyLive": "This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold." } } }, diff --git a/src/shared/worktree-removal-force-classification.test.ts b/src/shared/worktree-removal-force-classification.test.ts index 1d995d412f4..b4d95a5727d 100644 --- a/src/shared/worktree-removal-force-classification.test.ts +++ b/src/shared/worktree-removal-force-classification.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it } from 'vitest' -import { classifyWorktreeForceDeleteReason, WORKTREE_TEARDOWN_FORCE_HINT } from './worktree-removal' +import { + classifyWorktreeForceDeleteReason, + isProvenLivePtyRemovalError, + UNSTOPPED_PTY_REMOVAL_PREFIX, + WORKTREE_TEARDOWN_FORCE_HINT, + WORKTREE_TEARDOWN_TIMEOUT_PREFIX +} from './worktree-removal' + +const liveError = `${UNSTOPPED_PTY_REMOVAL_PREFIX} repo-1::/w — still live: term_a. ${WORKTREE_TEARDOWN_FORCE_HINT}` +const unverifiableError = `${UNSTOPPED_PTY_REMOVAL_PREFIX} repo-1::/w — could not verify these exited: term_a (daemon socket closed). ${WORKTREE_TEARDOWN_FORCE_HINT}` // Why (#11960): the desktop Force Delete button renders only when this classifier // returns a reason. The PTY-teardown error tells the user to force-delete, so an // unclassified message leaves them reading advice they cannot act on. describe('classifyWorktreeForceDeleteReason for unstopped PTYs', () => { - const liveError = `Failed to physically stop every PTY for worktree: repo-1::/w — still live: term_a. ${WORKTREE_TEARDOWN_FORCE_HINT}` - const unverifiableError = `Failed to physically stop every PTY for worktree: repo-1::/w — could not verify these exited: term_a (daemon socket closed). ${WORKTREE_TEARDOWN_FORCE_HINT}` - it('offers force for a PTY that is still live', () => { expect(classifyWorktreeForceDeleteReason(liveError)).toBe('unstopped-pty') }) @@ -29,7 +35,35 @@ describe('classifyWorktreeForceDeleteReason for unstopped PTYs', () => { expect(classifyWorktreeForceDeleteReason(liveError, false, true)).toBeNull() }) + // Why: a wedged provider rejects the sweep before any per-PTY verdict exists, so the + // failure arrives worded as a teardown timeout. The waiver clears it exactly like an + // unproven stop, so leaving it unclassified hid the button for the wedge #11960 targeted. + it('offers force when the teardown sweep itself timed out', () => { + expect( + classifyWorktreeForceDeleteReason( + `${WORKTREE_TEARDOWN_TIMEOUT_PREFIX} repo-1::/w. ${WORKTREE_TEARDOWN_FORCE_HINT}` + ) + ).toBe('unstopped-pty') + }) + it('leaves unrelated failures unclassified', () => { expect(classifyWorktreeForceDeleteReason('some other failure')).toBeNull() }) }) + +// The live verdict escalates the delete toast to "Force Delete will kill them", so it must +// come from Orca's own detail — not from the worktree id, which is a user-chosen path. +describe('isProvenLivePtyRemovalError', () => { + it('reads the verdict Orca actually recorded', () => { + expect(isProvenLivePtyRemovalError(liveError)).toBe(true) + expect(isProvenLivePtyRemovalError(unverifiableError)).toBe(false) + }) + + it('does not let a worktree path spell out a live verdict', () => { + expect( + isProvenLivePtyRemovalError( + `${UNSTOPPED_PTY_REMOVAL_PREFIX} repo-1::/Users/dev/still live: notes — could not verify these exited: term_a (daemon socket closed)` + ) + ).toBe(false) + }) +}) diff --git a/src/shared/worktree-removal.ts b/src/shared/worktree-removal.ts index 17e1b719cae..8788171ebe0 100644 --- a/src/shared/worktree-removal.ts +++ b/src/shared/worktree-removal.ts @@ -15,8 +15,34 @@ export type WorktreeForceDeleteReason = | 'missing-registration' | 'unstopped-pty' +// Why: everything before this separator is the worktree id — a user-chosen filesystem path. +// Only the detail after it is Orca's own wording, so verdict matchers anchor on the boundary +// rather than scanning the whole message and letting a path spell out a verdict. +export const UNSTOPPED_PTY_DETAIL_SEPARATOR = ' — ' + +// Why: verification distinguishes a PTY it watched stay alive from one it could not reach, +// and the delete toast must not flatten the two — a user waiving "we could not confirm" is +// making a different decision than one killing a terminal Orca just saw running. The marker +// and its matcher stay together for the same reason the force hint does. +export const UNSTOPPED_PTY_LIVE_DETAIL_PREFIX = 'still live:' + +// Why (#11960): a sweep that never answers wedges removal exactly like a stop that could not +// be proven, and the waiver clears both — but this error carries different words, so without +// its own matcher the force affordance stayed hidden for the very case it was added for. +export const WORKTREE_TEARDOWN_TIMEOUT_PREFIX = 'Timed out waiting for physical PTY teardown:' + export function isUnstoppedPtyRemovalError(error: string): boolean { - return error.includes(UNSTOPPED_PTY_REMOVAL_PREFIX) + return ( + error.includes(UNSTOPPED_PTY_REMOVAL_PREFIX) || error.includes(WORKTREE_TEARDOWN_TIMEOUT_PREFIX) + ) +} + +/** True only when verification positively observed the PTYs still running. */ +export function isProvenLivePtyRemovalError(error: string): boolean { + return ( + isUnstoppedPtyRemovalError(error) && + error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX}`) + ) } export function createLockedWorktreeRemovalError(lockReason?: string): Error { From dbb7804421f43b9058ce54bd0556e35a8026106c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:25:26 -0700 Subject: [PATCH 046/101] fix(updater): re-prove the retained Linux package before a privileged retry (#12395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(updater): re-prove the retained Linux package before a privileged retry The recovery card's "Try Automatic Install Again" handed electron-updater the cached .deb/.rpm path with no re-verification. That path is user-writable, so the digest proven when the card rendered says nothing about the bytes dpkg or rpm would read as root minutes later — and the card's other actions (copy command, reveal) validated while the one that actually installs did not. Re-hash the artifact immediately before the install, ahead of any destructive quit prep, and abort with copy that tells the user to download again. This narrows the window rather than closing it; only an immutable handoff would close it, which is a larger change. Also fix a macOS-only failure this suite gained with the platform-conditional pre-commit copy: the expectation hardcoded the non-Darwin string, so the suite was red on any Mac. * fix(updater): re-prove the retained Linux package on every install path Review findings on the original fix: 1. The abort force-sent its error status with no staleness guard, so a verdict from a hash that outlived its cycle overwrote whatever card had replaced it (a fresh 'available' from Check for Updates became a stale "package no longer matches" error). Now keyed on an install-cycle signature, the same protection failLinuxPackageRecovery already had. 2. 'read-failed' (EMFILE/EIO/EACCES mid-stream) was described as a digest mismatch and tore down the recovery card. It now reuses the accurate per-reason copy and keeps the card, exactly as the Copy/Show paths do for the same reason. It still fails closed: chmod 000 on a swapped file would otherwise be a one-line bypass, since root can read what we cannot. 3. The check was keyed on the recovery status, so it only covered the retry. The primary 'downloaded -> Restart to Update' install, whose window is hours rather than seconds, handed the same user-writable path to dpkg/rpm unverified. Moved into performQuitAndInstall keyed on the tracked artifact, so both paths are covered; non-Linux keeps its exact timing through a synchronous artifact guard. 4. The async prologue had moved the "quit timer is always cleared" invariant out of a try/finally. The re-proof now owns a flag cleared in finally, and a rejection fails closed instead of wedging the updater. 5. The install re-proof no longer joins an in-flight validation, so its proof cannot predate the click that asked for it. 6. The retry button gained the pending affordance the other actions have, since the click now streams the whole package before anything happens. Tests: real packages are staged in a real updater cache for the whole Linux block (a path that never existed would now abort every install); new cases cover the swapped primary install, the stale-verdict drop, the preserved card on read-failed, the rejecting re-proof, the concurrent second click, and the fresh-hash guarantee. Each was verified to fail with only its source change reverted. * fix(updater): tell the renderer when a stale verdict abandons the install The cycle guard that stops a stale digest verdict from clobbering a newer card also withheld the only signal the renderer has that the restart was called off. The preload abort relay keys on an 'error' status, so with the status suppressed the window stays restart-prepared for the rest of the session: Terminal/Settings skip their unsaved-work prompts and the shutdown checkpoint stays deduped, so a later real quit stages no fresh snapshot. Push the abandon from performQuitAndInstall's single return-false site, so it cannot depend on what the reporter decides about the status text, and relay it to the existing relay.abort() (a no-op unless the renderer armed a restart). The status stays cycle-guarded exactly as before. Tests: the stale-verdict and swapped-primary-install cases now assert the push, a committed install asserts its absence, and the preload relay test covers the new channel. Each fails with only its source change reverted. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../linux-package-update-recovery.test.ts | 58 +++ src/main/linux-package-update-recovery.ts | 52 ++- ...ter-linux-package-recovery-actions.test.ts | 4 + src/main/updater.test.ts | 366 ++++++++++++++++-- src/main/updater.ts | 125 +++++- src/preload/renderer-restart-wiring.test.ts | 10 +- src/preload/renderer-restart-wiring.ts | 6 +- .../LinuxPackageInstallRecoveryCard.test.tsx | 3 + .../LinuxPackageInstallRecoveryCard.tsx | 4 + 9 files changed, 577 insertions(+), 51 deletions(-) diff --git a/src/main/linux-package-update-recovery.test.ts b/src/main/linux-package-update-recovery.test.ts index e3c4a27533f..1d9bf8d8650 100644 --- a/src/main/linux-package-update-recovery.test.ts +++ b/src/main/linux-package-update-recovery.test.ts @@ -611,6 +611,31 @@ describePosix('validation coalescing', () => { expect(hashPasses.count).toBe(2) }) + // Why: a verdict handed to a root package manager must cover the bytes as of the click, not the + // bytes a Copy click started streaming seconds earlier. + it('never reuses an in-flight pass for a pre-install re-proof', async () => { + await writePackage('orca.deb') + capture() + const artifact = recovery.getTrackedLinuxPackageArtifact() + const copyPass = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) + const installPass = recovery.revalidateLinuxPackageForInstall(artifact!) + + await expect(installPass).resolves.toEqual({ ok: true }) + await expect(copyPass).resolves.toMatchObject({ ok: true }) + expect(hashPasses.count).toBe(2) + }) + + it('lets a later Copy click join the pre-install pass', async () => { + await writePackage('orca.deb') + capture() + const artifact = recovery.getTrackedLinuxPackageArtifact() + const installPass = recovery.revalidateLinuxPackageForInstall(artifact!) + const copyPass = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) + + await Promise.all([installPass, copyPass]) + expect(hashPasses.count).toBe(1) + }) + it('does not reuse an in-flight pass for a different artifact', async () => { await writePackage('orca.deb') await writePackage('orca-next.deb') @@ -629,6 +654,39 @@ describePosix('validation coalescing', () => { }) }) +describePosix('revalidateLinuxPackageForInstall', () => { + it('proves the retained package still matches its release digest', async () => { + await writePackage('orca.deb') + capture() + const artifact = recovery.getTrackedLinuxPackageArtifact() + await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ + ok: true + }) + }) + + it('rejects a package swapped after the download was verified', async () => { + await writePackage('orca.deb') + capture() + const artifact = recovery.getTrackedLinuxPackageArtifact() + await writePackage('orca.deb', 'attacker supplied package') + await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ + ok: false, + reason: 'hash-mismatch' + }) + }) + + it('reports a package deleted from the cache as missing', async () => { + const filePath = await writePackage('orca.deb') + capture() + const artifact = recovery.getTrackedLinuxPackageArtifact() + await fsp.rm(filePath) + await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ + ok: false, + reason: 'missing' + }) + }) +}) + describePosix('revealLinuxPackage', () => { it('reveals a verified package on the machine that owns it', async () => { const filePath = await writePackage('orca.deb') diff --git a/src/main/linux-package-update-recovery.ts b/src/main/linux-package-update-recovery.ts index 83aca03cb3f..f854b63c5b8 100644 --- a/src/main/linux-package-update-recovery.ts +++ b/src/main/linux-package-update-recovery.ts @@ -241,6 +241,29 @@ async function validateArtifact(artifact: LinuxPackageArtifact): Promise { + const key = `${artifact.packageType}:${artifact.version}:${artifact.path}:${artifact.sha512}` + if (!options?.fresh && inFlightValidation?.key === key) { + return inFlightValidation.promise + } + const promise: Promise = validateArtifact(artifact).finally(() => { + // Why: identity, not key — a fresh install pass may already have replaced this entry. + if (inFlightValidation?.promise === promise) { + inFlightValidation = null + } + }) + inFlightValidation = { key, promise } + return promise +} + /** * Revalidates the retained package against the digest captured from the release metadata. This is * an integrity check against that HTTPS metadata, not a package signature and not a privilege @@ -257,17 +280,7 @@ async function validateTrackedArtifact( ) { return { ok: false, reason: 'missing' } } - const key = `${artifact.packageType}:${artifact.version}:${artifact.path}:${artifact.sha512}` - if (inFlightValidation?.key === key) { - return inFlightValidation.promise - } - const promise = validateArtifact(artifact).finally(() => { - if (inFlightValidation?.key === key) { - inFlightValidation = null - } - }) - inFlightValidation = { key, promise } - return promise + return runValidation(artifact) } export async function resolveLinuxPackageInstallInstructions( @@ -289,6 +302,23 @@ export async function resolveLinuxPackageInstallInstructions( } } +/** + * Re-proves the retained package immediately before the privileged installer consumes it. + * + * The cache path is user-writable, so a digest checked when the download finished says nothing + * about the bytes `dpkg -i` will read minutes later. Re-hashing here does not close the race — + * only an immutable handoff would — but it shrinks the window from "since the download" to + * "since this call", and it catches the artifact being swapped or deleted outright. Takes the + * artifact rather than a recovery so both the retry and the plain "Restart to Update" install + * are covered. + */ +export async function revalidateLinuxPackageForInstall( + artifact: LinuxPackageArtifact +): Promise<{ ok: true } | { ok: false; reason: LinuxPackageRecoveryUnavailableReason }> { + const validation = await runValidation(artifact, { fresh: true }) + return validation.ok ? { ok: true } : { ok: false, reason: validation.reason } +} + export async function revealLinuxPackage( recovery: LinuxPackageInstallRecovery ): Promise { diff --git a/src/main/updater-linux-package-recovery-actions.test.ts b/src/main/updater-linux-package-recovery-actions.test.ts index 91b2423c817..75f593b7f57 100644 --- a/src/main/updater-linux-package-recovery-actions.test.ts +++ b/src/main/updater-linux-package-recovery-actions.test.ts @@ -9,6 +9,7 @@ const { getTrackedLinuxPackageArtifactMock, recordUpdaterLifecycleMock, resolveLinuxPackageInstallInstructionsMock, + revalidateLinuxPackageForInstallMock, revealLinuxPackageMock, resetHandlers } = vi.hoisted(() => { @@ -42,6 +43,7 @@ const { getTrackedLinuxPackageArtifactMock: vi.fn(), recordUpdaterLifecycleMock: vi.fn(), resolveLinuxPackageInstallInstructionsMock: vi.fn(), + revalidateLinuxPackageForInstallMock: vi.fn(), revealLinuxPackageMock: vi.fn(), resetHandlers: () => updaterHandlers.clear() } @@ -82,6 +84,7 @@ vi.mock('./linux-package-update-recovery', () => ({ clearTrackedLinuxPackageArtifactForOtherVersion: vi.fn(), getTrackedLinuxPackageArtifact: getTrackedLinuxPackageArtifactMock, resolveLinuxPackageInstallInstructions: resolveLinuxPackageInstallInstructionsMock, + revalidateLinuxPackageForInstall: revalidateLinuxPackageForInstallMock, revealLinuxPackage: revealLinuxPackageMock })) @@ -113,6 +116,7 @@ describe('linux package recovery actions', () => { resolveLinuxPackageInstallInstructionsMock .mockReset() .mockResolvedValue({ ok: true, command: "sudo apt install -- ''", packageFileName: 'p' }) + revalidateLinuxPackageForInstallMock.mockReset().mockResolvedValue({ ok: true }) revealLinuxPackageMock.mockReset().mockResolvedValue({ ok: true }) }) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 35a8c4c45bc..a0ab479343d 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1,6 +1,53 @@ /* eslint-disable max-lines */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type * as UpdaterModule from './updater' +import type * as RecoveryModule from './linux-package-update-recovery' +import type { UpdateStatus } from '../shared/types' + +type RevalidationVerdict = Awaited< + ReturnType +> + +// Captured before any vi.useFakeTimers() call: the only handle left that still yields to libuv. +const realSetTimeout = globalThis.setTimeout + +type StagedLinuxPackages = { + cacheRoot: string + debPath: string + debSha512: string + rpmPath: string + rpmSha512: string +} + +/** + * Stages real packages inside a real updater cache: every install re-proves the retained digest by + * streaming the file off disk, so a path that never existed would abort before reaching the native + * updater. Returns the actual digests for the download events. + */ +function stageLinuxUpdateCache(): StagedLinuxPackages { + const cacheRoot = mkdtempSync(join(tmpdir(), 'orca-updater-cache-')) + const pendingDir = join(cacheRoot, 'orca-updater', 'pending') + mkdirSync(pendingDir, { recursive: true }) + const stagePackage = (fileName: string): { path: string; sha512: string } => { + const packagePath = join(pendingDir, fileName) + const bytes = Buffer.from(`orca test package ${fileName}`) + writeFileSync(packagePath, bytes) + return { path: packagePath, sha512: createHash('sha512').update(bytes).digest('base64') } + } + const deb = stagePackage('orca-ide_1.0.61_amd64.deb') + const rpm = stagePackage('orca-ide-1.0.61.x86_64.rpm') + return { + cacheRoot, + debPath: deb.path, + debSha512: deb.sha512, + rpmPath: rpm.path, + rpmSha512: rpm.sha512 + } +} const { appMock, @@ -1780,8 +1827,12 @@ describe('updater', () => { 'updater:status', expect.objectContaining({ state: 'error', - // Why: a pre-commit install failure is not fixed by restarting, so the copy must not suggest it. - message: 'Could not start the update installer. Orca remains open.' + // Why: a pre-commit install failure is not fixed by restarting, so the copy must not + // suggest it — except on macOS, where quitting does re-stage a Squirrel update. + message: + process.platform === 'darwin' + ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' + : 'Could not start the update installer. Orca remains open.' }) ) }) @@ -3794,29 +3845,93 @@ describe('updater', () => { }) describe('linux root package install recovery', () => { - // Real 64-byte SHA-512 values; capture rejects a digest that cannot decode to one. - const DEB_SHA512 = - 'LHlL7dKoqg98gS2nfQv878dK+UoktbAkm4M20/hoJ2Qr0Kqsa3MSL4VmWy/Lll/MYjQFkpvOxduQ/vswentozA==' - const RPM_SHA512 = - 'W2U3AUPfVpc0Ia1qX/VJ5+8aW+yOFhysT6ryodo7A6DTZKqL6RYLK53U6ShGx+9f4lb35qtd4tOp5jzJZZdAfQ==' + let staged: StagedLinuxPackages + let EXIT_127: string + + // Why: the pre-install digest re-proof streams the package off real disk. Fake timers never + // advance libuv, so the quit timer needs fake time while the read needs real event-loop turns. + const settleQuitAndInstall = async (): Promise => { + await vi.advanceTimersByTimeAsync(100) + for (let turn = 0; turn < 40; turn += 1) { + await new Promise((resolve) => realSetTimeout(resolve, 0)) + } + await vi.advanceTimersByTimeAsync(0) + } + + beforeEach(() => { + staged = stageLinuxUpdateCache() + vi.stubEnv('XDG_CACHE_HOME', staged.cacheRoot) + EXIT_127 = `Command failed: /usr/bin/pkexec /usr/bin/dpkg -i ${staged.debPath}, exited with code 127` + }) + + afterEach(() => { + vi.doUnmock('./linux-package-update-recovery') + vi.unstubAllEnvs() + rmSync(staged.cacheRoot, { recursive: true, force: true }) + }) + + /** + * Holds the pre-install re-proof open so a verdict can be delivered at an exact point in the + * cycle. Only that call is replaced — the artifact state stays real. Must precede startUpdater. + */ + const holdRevalidation = (): { + settle: (verdict: RevalidationVerdict) => void + fail: (error: Error) => void + } => { + let pending: { + resolve: (verdict: RevalidationVerdict) => void + reject: (error: Error) => void + } | null = null + vi.doMock('./linux-package-update-recovery', async () => { + const actual = await vi.importActual( + './linux-package-update-recovery' + ) + return { + ...actual, + revalidateLinuxPackageForInstall: vi.fn( + () => + new Promise((resolve, reject) => { + pending = { resolve, reject } + }) + ) + } + }) + return { + settle: (verdict) => { + pending?.resolve(verdict) + pending = null + }, + fail: (error) => { + pending?.reject(error) + pending = null + } + } + } + + const lastStatus = (send: ReturnType): UpdateStatus | undefined => + send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1] + // Why: macOS keeps the restart advice because quitting does re-stage a Squirrel update. const PRE_COMMIT_FAILURE_MESSAGE = process.platform === 'darwin' ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' : 'Could not start the update installer. Orca remains open.' - const DEB_PATH = '/home/tester/.cache/orca-updater/pending/orca-ide_1.0.61_amd64.deb' - const RPM_PATH = '/home/tester/.cache/orca-updater/pending/orca-ide-1.0.61.x86_64.rpm' const AGENT_STDERR = 'pkexec: Error executing command as another user: No authentication agent found.' - const EXIT_127 = `Command failed: /usr/bin/pkexec /usr/bin/dpkg -i ${DEB_PATH}, exited with code 127` const downloadedEvent = (overrides?: Record): Record => ({ version: '1.0.61', - downloadedFile: DEB_PATH, - files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: DEB_SHA512 }], + downloadedFile: staged.debPath, + files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: staged.debSha512 }], ...overrides }) + const rpmDownloadedEvent = (): Record => + downloadedEvent({ + downloadedFile: staged.rpmPath, + files: [{ url: 'orca-ide-1.0.61.x86_64.rpm', sha512: staged.rpmSha512 }] + }) + const startUpdater = async ( packageType: 'deb' | 'rpm' | null ): Promise<{ send: ReturnType; updater: typeof UpdaterModule }> => { @@ -3902,12 +4017,12 @@ describe('updater', () => { const { send, updater } = await startUpdater('deb') await reachDownloaded(updater, downloadedEvent()) autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${DEB_PATH}`) + autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`) throw new Error(EXIT_127) }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() // Why: the sync throw ends capture before the catch, so the stashed text must survive. expect(send).toHaveBeenCalledWith('updater:status', { @@ -3926,19 +4041,13 @@ describe('updater', () => { const openWindow = { removeAllListeners: vi.fn() } browserWindowMock.getAllWindows.mockReturnValue([openWindow] as never) const { send, updater } = await startUpdater('rpm') - await reachDownloaded( - updater, - downloadedEvent({ - downloadedFile: RPM_PATH, - files: [{ url: 'orca-ide-1.0.61.x86_64.rpm', sha512: RPM_SHA512 }] - }) - ) + await reachDownloaded(updater, rpmDownloadedEvent()) autoUpdaterMock.quitAndInstall.mockImplementation(() => { autoUpdaterMock.emit('error', new Error('Command failed, exited with code 1')) }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() expect(send).toHaveBeenCalledWith('updater:status', { state: 'error', @@ -3968,7 +4077,7 @@ describe('updater', () => { }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() expect(send).toHaveBeenCalledWith('updater:status', { state: 'error', @@ -3992,7 +4101,7 @@ describe('updater', () => { }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() expect(send).toHaveBeenCalledWith('updater:status', { @@ -4012,7 +4121,7 @@ describe('updater', () => { send.mockClear() updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( 'post_commit_cleanup_failed', @@ -4030,7 +4139,7 @@ describe('updater', () => { await reachDownloaded(updater, downloadedEvent()) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() expect(killAllPtyMock).toHaveBeenCalledTimes(1) send.mockClear() @@ -4048,9 +4157,9 @@ describe('updater', () => { }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() // Why: a retry usually fails identically; a deduped status would strand the preload restart relay. expect( @@ -4069,16 +4178,205 @@ describe('updater', () => { }) }) - it('records classification-only lifecycle data for a package install failure', async () => { - const { updater } = await startUpdater('deb') + // Why: the cache path is user-writable, so the bytes verified when the recovery card + // rendered are not necessarily the bytes a root package manager would read on retry. + it('aborts the retry when the retained package no longer matches its digest', async () => { + const { send, updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + + // The escalation fails, which is what puts the recovery card (and its retry) on screen. + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit('error', new Error(EXIT_127)) + }) + updater.quitAndInstall() + await settleQuitAndInstall() + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + + // A local process swaps the verified package for its own between failure and retry. + writeFileSync(staged.debPath, Buffer.from('attacker supplied package')) + send.mockClear() + killAllPtyMock.mockClear() + + updater.quitAndInstall() + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(updater.isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: + 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.' + }) + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( + 'linux_package_revalidation_failed', + expect.objectContaining({ action: 'retry-automatic', reason: 'hash-mismatch' }), + expect.anything() + ) + }) + + // Why: "Restart to Update" is the common path and can sit unclicked for hours, so the same + // user-writable package reaches a root installer with a far longer window than any retry. + it('aborts the first install when the downloaded package was swapped', async () => { + const { send, updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + writeFileSync(staged.debPath, Buffer.from('attacker supplied package')) + send.mockClear() + + updater.quitAndInstall() + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(updater.isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: + 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.' + }) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( + 'linux_package_revalidation_failed', + expect.objectContaining({ action: 'restart-to-install', reason: 'hash-mismatch' }), + expect.anything() + ) + }) + + it('installs normally when the retained package still matches its digest', async () => { + const { send, updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + + updater.quitAndInstall() + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(killAllPtyMock).toHaveBeenCalledTimes(1) + // Why: an abort push here would clear the restart flag mid-quit and re-arm the dirty-buffer + // prompt against the install that is already committed. + expect(send).not.toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith( + 'linux_package_revalidation_failed', + expect.anything(), + expect.anything() + ) + }) + + // Why: hashing 160 MB outlives the cycle it started in, and Check for Updates stays enabled + // while it runs — a verdict from the old cycle must not replace the card that took over. + it('drops an abort verdict once a newer check replaced the card', async () => { + const revalidation = holdRevalidation() + const { send, updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + // The user gives up waiting and checks again; that check owns the card from here. + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' }) + + revalidation.settle({ ok: false, reason: 'hash-mismatch' }) + await settleQuitAndInstall() + + // The install is still abandoned — only the stale status is withheld. + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' }) + // Withholding the status must not also withhold the abort: the renderer armed its restart and + // would otherwise skip its unsaved-work prompt for the rest of the session. + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( + 'linux_package_revalidation_failed', + expect.objectContaining({ reason: 'hash-mismatch' }), + expect.anything() + ) + }) + + // Why: EMFILE/EIO during the stream says nothing about the bytes, so the copy must not claim + // the package changed and the card must keep the actions that still work. + it('keeps the recovery card usable when the re-proof cannot read the package', async () => { + const revalidation = holdRevalidation() + const { send, updater } = await startUpdater('deb') await reachDownloaded(updater, downloadedEvent()) autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${DEB_PATH}`) autoUpdaterMock.emit('error', new Error(EXIT_127)) }) + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + revalidation.settle({ ok: true }) + await settleQuitAndInstall() + send.mockClear() + + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + revalidation.settle({ ok: false, reason: 'read-failed' }) + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(lastStatus(send)).toEqual({ + state: 'error', + message: + 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'package-install-failed', + version: '1.0.61' + } + }) + }) + + // Why: the re-proof runs before performQuitAndInstall's own error handling, so a rejection + // there would strand the quit timer and make every later install a silent no-op. + it('stays installable after a re-proof that rejects outright', async () => { + const revalidation = holdRevalidation() + const { send, updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + revalidation.fail(new Error('hash worker crashed')) + await settleQuitAndInstall() + + // Fails closed: an unprovable package is not handed to a root package manager. + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(lastStatus(send)).toMatchObject({ + state: 'error', + message: + 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.' + }) updater.quitAndInstall() await vi.advanceTimersByTimeAsync(100) + revalidation.settle({ ok: true }) + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + // Why: a second click during the multi-second hash must not schedule a parallel install. + it('ignores a second install request while the digest re-proof runs', async () => { + const { updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + + updater.quitAndInstall() + // Fires the quit timer, which starts the hash; the read itself is still outstanding. + await vi.advanceTimersByTimeAsync(100) + updater.quitAndInstall() + await settleQuitAndInstall() + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it('records classification-only lifecycle data for a package install failure', async () => { + const { updater } = await startUpdater('deb') + await reachDownloaded(updater, downloadedEvent()) + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`) + autoUpdaterMock.emit('error', new Error(EXIT_127)) + }) + + updater.quitAndInstall() + await settleQuitAndInstall() const failure = recordUpdaterLifecycleMock.mock.calls.find( ([event]) => event === 'linux_package_install_failed' @@ -4091,7 +4389,7 @@ describe('updater', () => { errorType: 'Error' }) const durable = JSON.stringify(recordUpdaterLifecycleMock.mock.calls) - expect(durable).not.toContain(DEB_PATH) + expect(durable).not.toContain(staged.debPath) expect(durable).not.toContain('authentication agent') }) @@ -4103,7 +4401,7 @@ describe('updater', () => { }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleQuitAndInstall() const failure = recordUpdaterLifecycleMock.mock.calls.find( ([event]) => event === 'linux_package_install_failed' diff --git a/src/main/updater.ts b/src/main/updater.ts index 6bb26d84dd5..302591286f3 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -45,7 +45,9 @@ import { clearTrackedLinuxPackageArtifact, getTrackedLinuxPackageArtifact, resolveLinuxPackageInstallInstructions, + revalidateLinuxPackageForInstall, revealLinuxPackage, + type LinuxPackageArtifact, type LinuxPackageRecoveryUnavailableReason } from './linux-package-update-recovery' import { @@ -124,6 +126,9 @@ let autoUpdateCheckTimer: ReturnType | null = null let nudgeCheckTimer: ReturnType | null = null let pendingQuitAndInstallTimer: ReturnType | null = null let quitAndInstallInProgress = false +// Why: the pre-install digest re-proof streams the whole package, so a second install request can +// arrive while it runs — after the quit timer was cleared but before the handoff owns the process. +let linuxPackageRevalidationInFlight = false let updateInstallMode: UpdateInstallMode = 'interactive' let lastInstallDeferralVersion = { download: null as string | null, install: null as string | null } // Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer. @@ -693,7 +698,7 @@ function clearPrereleaseFallbackContextIfSettled(): void { } async function performQuitAndInstall(): Promise { - if (quitAndInstallInProgress) { + if (quitAndInstallInProgress || linuxPackageRevalidationInFlight) { recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) return } @@ -707,6 +712,17 @@ async function performQuitAndInstall(): Promise { if (deferHeadlessServeInstall('install', pendingVersion)) { return } + // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about + // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a + // swapped or vanished package aborts instead of being installed as root. The synchronous guard + // keeps every non-Linux install on its existing timing. + if (getTrackedLinuxPackageArtifact() && !(await proveRetainedLinuxPackage(pendingVersion))) { + // Why: the renderer armed its restart before invoking, and it infers the abort from the error + // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where + // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. + mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + return + } quitAndInstallInProgress = true markMacQuitAndInstallInFlight() @@ -1801,6 +1817,108 @@ function failLinuxPackageRecovery( throw new Error(message) } +/** + * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash + * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. + */ +function getInstallCycleSignature(): string { + const recovery = getActiveLinuxPackageRecovery() + if (recovery) { + return `recovery:${recovery.packageType}:${recovery.version}` + } + return currentStatus.state === 'downloaded' + ? `downloaded:${currentStatus.version}` + : `state:${currentStatus.state}` +} + +/** + * Re-proves the retained package before the install starts. Returns false when the install must be + * abandoned; the artifact is only re-read here, so callers still own every teardown decision. + */ +async function proveRetainedLinuxPackage(pendingVersion: string): Promise { + const artifact = getTrackedLinuxPackageArtifact() + if (!artifact) { + return true + } + // Why: an artifact retained from another cycle says nothing about the file electron-updater is + // about to install, so proving it would block a legitimate install on an unrelated digest. + if (pendingVersion && pendingVersion !== artifact.version) { + return true + } + const recovery = getActiveLinuxPackageRecovery() + const cycle = getInstallCycleSignature() + const reason = await revalidateRetainedLinuxPackage(artifact) + if (!reason) { + return true + } + reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) + return false +} + +/** The failing reason, or null when the retained package still matches its release digest. */ +async function revalidateRetainedLinuxPackage( + artifact: LinuxPackageArtifact +): Promise { + linuxPackageRevalidationInFlight = true + try { + const verdict = await revalidateLinuxPackageForInstall(artifact) + return verdict.ok ? null : verdict.reason + } catch (error) { + recordUpdaterLifecycle( + 'linux_package_revalidation_errored', + { errorType: error instanceof Error ? error.name : typeof error }, + { level: 'warn', message: 'Could not re-verify the retained update package' } + ) + // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. + return 'read-failed' + } finally { + // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall + // early-return for the rest of the session. + linuxPackageRevalidationInFlight = false + } +} + +function reportLinuxPackageRevalidationFailure({ + artifact, + recovery, + reason, + cycle +}: { + artifact: LinuxPackageArtifact + recovery: LinuxPackageInstallRecovery | null + reason: LinuxPackageRecoveryUnavailableReason + cycle: string +}): void { + recordUpdaterLifecycle( + 'linux_package_revalidation_failed', + { + action: recovery ? 'retry-automatic' : 'restart-to-install', + packageType: artifact.packageType, + version: artifact.version, + reason + }, + { level: 'warn', message: 'Retained update package failed its pre-install digest check' } + ) + // Why: a package proven bad must not stay tracked, but a download that landed during the hash + // owns the slot now and destroying it would force a needless 160 MB redownload. + const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) + if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { + clearTrackedLinuxPackageArtifact() + } + // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been + // replaced must not clobber whatever card the user is looking at now. + if (getInstallCycleSignature() !== cycle) { + return + } + sendInstallFailureStatus({ + state: 'error', + message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], + // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its + // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. + ...(recovery && !clearsArtifact ? { recovery } : {}) + }) +} + export async function getLinuxPackageInstallInstructions(): Promise { const recovery = getActiveLinuxPackageRecovery() if (!recovery) { @@ -1849,7 +1967,10 @@ export function quitAndInstall(): void { localBuildSelectionInProgress || pinnedBuildSelectionInProgress || pendingQuitAndInstallTimer || - quitAndInstallInProgress + quitAndInstallInProgress || + // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so + // without this a second click would schedule a parallel install of the same package. + linuxPackageRevalidationInFlight ) { return } diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index 06be6537fcb..bd896109b57 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -10,11 +10,12 @@ import { } from './renderer-restart-wiring' describe('renderer restart wiring', () => { - it('relays updater status and prevented unload events', () => { + it('relays updater status, aborted installs, and prevented unload events', () => { const eventTarget = new EventTarget() const unloadPrevented = vi.fn() const restartAborted = vi.fn() const handleStatus = vi.fn() + const abort = vi.fn() const listeners = new Map void>() const ipcRenderer = { on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { @@ -25,12 +26,15 @@ describe('renderer restart wiring', () => { eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, unloadPrevented) eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, restartAborted) - registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus }) + registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus, abort }) listeners.get('updater:status')?.({}, { state: 'error', message: 'install failed' }) + // Why: main abandons an install without any status when its verdict outlived the cycle. + listeners.get('updater:quitAndInstallAborted')?.({}) listeners.get('window:unload-prevented')?.({}) - expect(ipcRenderer.on).toHaveBeenCalledTimes(2) + expect(ipcRenderer.on).toHaveBeenCalledTimes(3) expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' }) + expect(abort).toHaveBeenCalledTimes(1) expect(unloadPrevented).toHaveBeenCalledTimes(1) expect(restartAborted).toHaveBeenCalledTimes(1) }) diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index 9b140e7116b..0a177dd55b0 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -15,11 +15,15 @@ import { export function registerRendererRestartIpcRelays( ipcRenderer: Pick, eventTarget: EventTarget, - relay: Pick + relay: Pick ): void { ipcRenderer.on('updater:status', (_event, status: UpdateStatus) => { relay.handleStatus(status) }) + // Why: main abandons some installs without an error status, and only this tells the renderer. + ipcRenderer.on('updater:quitAndInstallAborted', () => { + relay.abort() + }) ipcRenderer.on('window:unload-prevented', () => { eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) diff --git a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx index b42844611b4..e0685b55fbe 100644 --- a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx +++ b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx @@ -484,6 +484,9 @@ describe('LinuxPackageInstallRecoveryCard retry', () => { fireEvent.click(button('Try Automatic Install Again')) await flushActions() + // Why: the retry now re-proves the package digest before quitting, so the click must report + // progress instead of leaving three inert buttons for the length of the hash. + expect(isAriaDisabled(button('Checking package...'))).toBe(true) // Why: quitAndInstall resolves as soon as main schedules the install, so a resolved promise is // not an outcome — the slot stays held until a real status arrives. expect(isAriaDisabled(button('Copy Install Command'))).toBe(true) diff --git a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx index 96d6b3cd5ee..3d89b7ee6b7 100644 --- a/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx +++ b/src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx @@ -198,6 +198,10 @@ export function LinuxPackageInstallRecoveryCard({ 'auto.components.LinuxPackageInstallRecoveryCard.3da99454c6', 'Try Automatic Install Again' ), + // Why: the retry re-proves the package digest before it quits, so the click is no longer + // instant — without this the card would just go inert for the length of the hash. + pendingLabel: CHECKING_LABEL, + isPending: pendingAction === 'retry', disabled: pendingAction !== null, onClick: handleRetryAutomatic } From 141b1f43f632c3ec78e92113dda54ab4c8428db5 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:25:44 -0700 Subject: [PATCH 047/101] fix(runtime): keep the listener on loopback for a "This computer only" pairing link (#12405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): keep the listener on loopback for a "This computer only" pairing link The runtime pairing URL handler called ensureNetworkExposure() for every offer, including one whose advertised address is loopback. Settings -> "Share this Orca server" offers a "This computer only" radio that pairs against 127.0.0.1 precisely so nothing is reachable off-host, yet choosing it rebound the WebSocket listener from 127.0.0.1 to 0.0.0.0 — and the widen never narrows back, so the runtime stayed exposed to the whole LAN for the rest of the process after the user picked the option that exists to avoid exactly that. Gate the widen on the advertised address: only a non-loopback endpoint (LAN, Tailscale, custom host) needs a listener reachable off this machine, so those paths keep widening exactly as STA-2370 intended. A loopback link is already served by the loopback listener, so it now mints without touching the bind. Classification reuses the shared pairing-address classifier, which also covers localhost, ::1 and 127.0.0.0/8 typed into the custom-address field. Tests: a real OrcaRuntimeRpcServer driven through the IPC handler asserts the bind host stays 127.0.0.1 after a local link and flips to 0.0.0.0 after a LAN one, plus handler-level cases for 127.0.0.1 / localhost / ::1. * fix(runtime): gate the pairing widen on the user's declared reach, not the address shape Review of #12405 found two ways the loopback fix misbehaved. 1. The guarantee died at the next launch. resolveInitialWebSocketBindHost() binds 0.0.0.0 whenever any device has lastSeenAt > 0, and MobileSocketWiring stamps that for EVERY authenticated socket — including the local browser opening a "This computer only" link. So the runtime was still published on every interface, one restart later. Grants now carry the reach they were minted for (DeviceEntry.pairingReach, persisted); a this-computer grant no longer counts as proof that an off-host client may reconnect. Registries written before the field default to network reach, so an already-paired phone still finds a wide listener after upgrading. A pending grant that is re-advertised for the network widens (never narrows) so its link survives. 2. The widen was gated on the shape of the typed address, which the renderer never sent the intent for. A Custom `127.0.0.1:8443` — the documented SSH tunnel / reverse proxy field — skipped the widen and produced a dead link, while `localhost:8443`, `[::1]:6768` and `ws://127.0.0.1:6768` widened, so the same loopback intent was handled three different ways. The renderer now sends the declared reach ('this-computer' | 'network') and main gates on it; the address is only used as a mismatch guard (a this-computer reach carrying an off-host address still widens rather than minting an unreachable link), resolved through resolveAdvertisedPairingHostname so every accepted address form classifies identically. Also corrected the ensureNetworkExposure invariant comment: the widen is no longer confined to the first pairing action, so it can now tear down live loopback sockets — they reconnect on the reused pinned port. Tests: reach-form matrix + tunnel/undeclared/mismatch cases in mobile.test.ts, real-server relaunch bind for both reaches, legacy registry compatibility, the pending-grant reach upgrade, a live-client port-stability guard, hostname resolver coverage, and the renderer reach plumbing. Reverting only the source fails 18 of them. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/ipc/mobile.test.ts | 246 +++++++++++++++++- src/main/ipc/mobile.ts | 53 ++-- src/main/runtime/device-registry.ts | 57 +++- src/main/runtime/pairing-endpoint.test.ts | 33 ++- src/main/runtime/pairing-endpoint.ts | 17 ++ src/main/runtime/runtime-rpc.test.ts | 143 ++++++++++ src/main/runtime/runtime-rpc.ts | 26 +- src/preload/api-types.ts | 7 +- src/preload/index.ts | 4 + .../RuntimePairingUrlGenerator.test.tsx | 46 +++- .../settings/RuntimePairingUrlGenerator.tsx | 6 +- .../settings/runtime-pairing-link-state.ts | 8 + src/shared/runtime-pairing-reach.ts | 5 + 13 files changed, 608 insertions(+), 43 deletions(-) create mode 100644 src/shared/runtime-pairing-reach.ts diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 54458541f92..9f81c3a6516 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type * as NodeOs from 'node:os' const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -17,12 +18,21 @@ vi.mock('qrcode', () => ({ } })) -vi.mock('os', () => ({ +// Why: only the interface enumeration is faked; the real `os` stays available for the integration +// test below, which needs tmpdir() for a real runtime's user data directory. +vi.mock('os', async (importOriginal) => ({ + ...(await importOriginal()), networkInterfaces: networkInterfacesMock })) +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { registerMobileHandlers } from './mobile' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { WebSocketTransport } from '../runtime/rpc/ws-transport' describe('registerMobileHandlers', () => { const handlers = new Map unknown>() @@ -308,7 +318,8 @@ describe('registerMobileHandlers', () => { address: '100.64.1.20', rotate: true, name: expect.stringMatching(/^Runtime /), - scope: 'runtime' + scope: 'runtime', + reach: 'network' }) // Why: STA-2370 — generating a runtime offer must widen the listener BEFORE advertising its endpoint, // or a client could read the URL and connect before the LAN bind exists. Assert call ORDER, not just @@ -319,6 +330,111 @@ describe('registerMobileHandlers', () => { ) }) + const stubRuntimePairingServer = ( + address: string + ): { createPairingOffer: Mock; ensureNetworkExposure: Mock } => ({ + createPairingOffer: vi.fn().mockReturnValue({ + available: true, + pairingUrl: 'orca://pair#runtime', + webClientUrl: `http://${address}/web-index.html?pairing=runtime`, + endpoint: `ws://${address}`, + deviceId: 'runtime-local' + }), + ensureNetworkExposure: vi.fn().mockResolvedValue(undefined) + }) + + // Why: every loopback form the address field accepts must behave identically once the user declared + // "This computer only" — `splitHostPort`/`ws://` overrides all reach the handler verbatim, so gating on + // the raw string alone treated `localhost:8443` and `ws://127.0.0.1:6768` as off-host. + it.each([ + '127.0.0.1', + 'localhost', + '::1', + '127.0.0.1:8443', + 'localhost:8443', + '[::1]:6768', + 'ws://127.0.0.1:6768' + ])('mints a %s runtime link without widening the listener off-host', async (address) => { + const rpcServer = stubRuntimePairingServer(address) + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address, + rotate: true, + reach: 'this-computer' + }) + ).resolves.toMatchObject({ available: true, deviceId: 'runtime-local' }) + + // Why: "This computer only" exists so nothing is exposed off-host — a loopback link is already + // served by the loopback listener, and the widen is one-way for the life of the process. + expect(rpcServer.ensureNetworkExposure).not.toHaveBeenCalled() + expect(rpcServer.createPairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ reach: 'this-computer' }) + ) + }) + + // Why: the Custom field is documented as "SSH tunnel, reverse proxy, or custom hostname", so a loopback + // front-end there still needs the listener open behind it — gating the widen on the address SHAPE broke + // `ssh -L 8443::6768` (sshd dials the LAN address on this host and gets refused). + it.each(['127.0.0.1:8443', 'localhost', 'ws://127.0.0.1:6768'])( + 'widens the listener for a custom %s address that fronts a tunnel', + async (address) => { + const rpcServer = stubRuntimePairingServer(address) + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address, + rotate: true, + reach: 'network' + }) + ).resolves.toMatchObject({ available: true }) + + expect(rpcServer.ensureNetworkExposure).toHaveBeenCalled() + expect(rpcServer.createPairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ reach: 'network' }) + ) + } + ) + + it('widens for a loopback address when the caller declares no reach', async () => { + const rpcServer = stubRuntimePairingServer('127.0.0.1:6768') + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { address: '127.0.0.1', rotate: true }) + ).resolves.toMatchObject({ available: true }) + + // Why: only an explicit "This computer only" declines remote reach; an undeclared caller keeps the + // pre-STA-2370 opt-in behavior rather than silently minting a link the widen never backed. + expect(rpcServer.ensureNetworkExposure).toHaveBeenCalled() + }) + + it('widens when a this-computer reach carries an off-host address', async () => { + const rpcServer = stubRuntimePairingServer('192.168.1.5:6768') + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '192.168.1.5', + rotate: true, + reach: 'this-computer' + }) + ).resolves.toMatchObject({ available: true }) + + // Why: the declared reach and the advertised address disagree; honoring the declaration would mint a + // LAN link with no LAN listener behind it, so the address it actually publishes wins. + expect(rpcServer.ensureNetworkExposure).toHaveBeenCalled() + expect(rpcServer.createPairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ reach: 'network' }) + ) + }) + it('reports the runtime pairing url unavailable and mints no offer when the widen fails', async () => { const createPairingOffer = vi.fn() // Why: STA-2370 — a failed widen leaves the listener on loopback, so the handler must NOT advertise a @@ -496,3 +612,127 @@ describe('registerMobileHandlers', () => { expect(runPowerShell).not.toHaveBeenCalled() }) }) + +describe('runtime pairing bind host', () => { + const handlers = new Map unknown>() + + const wsTransportOf = (server: OrcaRuntimeRpcServer): WebSocketTransport | undefined => + (server['activeTransports'] as unknown[]).find( + (transport): transport is WebSocketTransport => transport instanceof WebSocketTransport + ) + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + networkInterfacesMock.mockReset() + networkInterfacesMock.mockReturnValue({}) + handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + }) + + const startServer = async (userDataPath: string): Promise => { + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + return server + } + + it('keeps a real listener on loopback for a local link and widens it only for an off-host one', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-ipc-')) + const server = await startServer(userDataPath) + + try { + registerMobileHandlers(server) + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '127.0.0.1', + rotate: true, + reach: 'this-computer' + }) + ).resolves.toMatchObject({ available: true }) + // Why: the exposure regression — picking "This computer only" used to rebind the listener to + // 0.0.0.0 and leave it there, publishing the runtime to the whole LAN for the rest of the process. + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '100.64.1.20', + rotate: true, + reach: 'network' + }) + ).resolves.toMatchObject({ available: true }) + // Why: the LAN/Tailscale choice still opts in — a client off this host cannot reach a loopback bind. + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + } finally { + await server.stop() + } + }) + + // Why: the whole guarantee is worthless if it lasts one process — the local web client authenticating + // marks its grant lastSeenAt > 0, which used to make the NEXT launch bind every interface. + it('still binds loopback on the next launch after the local link has been used', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-ipc-')) + const server = await startServer(userDataPath) + let deviceId: string + try { + registerMobileHandlers(server) + const offer = (await handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '127.0.0.1', + rotate: true, + reach: 'this-computer' + })) as { available: true; deviceId: string } + expect(offer.available).toBe(true) + deviceId = offer.deviceId + // Exactly what MobileSocketWiring does for every authenticated socket, local browser included. + server.getDeviceRegistry()?.updateLastSeen(deviceId) + } finally { + await server.stop() + } + + const relaunched = await startServer(userDataPath) + try { + expect( + relaunched + .getDeviceRegistry() + ?.listDevices() + .some((device) => device.deviceId === deviceId && device.lastSeenAt > 0) + ).toBe(true) + expect(wsTransportOf(relaunched)?.resolvedHost).toBe('127.0.0.1') + } finally { + await relaunched.stop() + } + }) + + it('binds all interfaces on the next launch after a network link has been used', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-ipc-')) + const server = await startServer(userDataPath) + try { + registerMobileHandlers(server) + const offer = (await handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '100.64.1.20', + rotate: true, + reach: 'network' + })) as { available: true; deviceId: string } + expect(offer.available).toBe(true) + server.getDeviceRegistry()?.updateLastSeen(offer.deviceId) + } finally { + await server.stop() + } + + const relaunched = await startServer(userDataPath) + try { + // Why: the reconnect widen must survive — a client that really did connect from off-host has to + // find the listener at launch without the user re-pairing. + expect(wsTransportOf(relaunched)?.resolvedHost).toBe('0.0.0.0') + } finally { + await relaunched.stop() + } + }) +}) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index ff4c5c6d3ec..35584f951a4 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,9 +2,12 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import { networkInterfaces } from 'node:os' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import { isTailnetIPv4Address } from '../../shared/tailnet-address' import type { DeviceEntry } from '../runtime/device-registry' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' +import { resolveAdvertisedPairingHostname } from '../runtime/pairing-endpoint' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr' @@ -77,6 +80,18 @@ function getDefaultPairingAddress(): string | null { return ifaces.length > 0 ? ifaces[0]!.address : null } +// Why: only an explicit "This computer only" pick skips the one-way widen, and only when the address it +// advertises really is loopback — a mismatch (a LAN address under a this-computer reach) would otherwise +// mint a link with no listener behind it. Every other reach, including a loopback-looking Custom address +// that fronts an SSH tunnel or reverse proxy, still opts in. +function servesThisComputerOnly(reach: RuntimePairingReach | undefined, address: string): boolean { + if (reach !== 'this-computer') { + return false + } + const hostname = resolveAdvertisedPairingHostname(address) + return hostname !== null && classifyRemotePairingHostname(hostname) === 'loopback' +} + function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant { return { deviceId: device.deviceId, @@ -175,7 +190,7 @@ export function registerMobileHandlers( ipcMain.handle( 'mobile:getRuntimePairingUrl', - async (_event, args?: { address?: string; rotate?: boolean }) => { + async (_event, args?: { address?: string; rotate?: boolean; reach?: RuntimePairingReach }) => { const ip = args?.address ?? getDefaultPairingAddress() if (!ip) { return { available: false as const } @@ -184,19 +199,24 @@ export function registerMobileHandlers( // Why: STA-2370 — generating a runtime pairing offer is the user's explicit opt-in to remote // reach, so widen the loopback listener before advertising its LAN endpoint. If the widen fails the // listener stays on loopback, so report unavailable rather than advertise a dead LAN endpoint. - try { - await rpcServer.ensureNetworkExposure() - } catch (error) { - console.error( - '[mobile] Network exposure failed while creating a runtime pairing offer:', - error - ) - // Why: STA-2370 — carry the specific reason/guidance to the renderer (mirrors the mobile-QR path) so - // a widen failure is distinguishable from a missing address, not collapsed into a bare unavailable. - return { - available: false as const, - reason: 'network_exposure_failed' as const, - guidance: NETWORK_EXPOSURE_FAILED_GUIDANCE + // "This computer only" is the opposite opt-in: the loopback listener already serves it, and the widen + // never narrows back, so that pick alone must not expose the runtime off-host. + const thisComputerOnly = servesThisComputerOnly(args?.reach, ip) + if (!thisComputerOnly) { + try { + await rpcServer.ensureNetworkExposure() + } catch (error) { + console.error( + '[mobile] Network exposure failed while creating a runtime pairing offer:', + error + ) + // Why: STA-2370 — carry the specific reason/guidance to the renderer (mirrors the mobile-QR path) so + // a widen failure is distinguishable from a missing address, not collapsed into a bare unavailable. + return { + available: false as const, + reason: 'network_exposure_failed' as const, + guidance: NETWORK_EXPOSURE_FAILED_GUIDANCE + } } } @@ -206,7 +226,10 @@ export function registerMobileHandlers( address: ip, rotate: args?.rotate, name: `Runtime ${new Date().toLocaleDateString()}`, - scope: 'runtime' + scope: 'runtime', + // Why: a grant that only ever pointed at loopback must not make the next launch bind every + // interface when its local client reconnects (that would restore the exposure one restart later). + reach: thisComputerOnly ? 'this-computer' : 'network' }) if (!offer.available) { return { available: false as const } diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 959b52c9474..7de8af08fde 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -10,6 +10,7 @@ import type { DeviceScope } from '../../shared/runtime-types' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' export type { DeviceScope } @@ -22,6 +23,9 @@ export type DeviceEntry = { lastSeenAt: number relayBinding?: RelayDeviceBinding mobilePairingConnectionMode?: MobilePairingConnectionMode + // Why: STA-2370 — a grant minted for "This computer only" proves nothing about off-host reach when its + // client connects, so the bind decision must be able to tell it apart from a LAN/phone grant. + pairingReach?: RuntimePairingReach } function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { @@ -52,14 +56,19 @@ export class DeviceRegistry { this.load() } - addDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { - return this.createAndPersistDevice(this.devices, name, scope) + addDevice( + name: string, + scope: DeviceScope = 'mobile', + pairingReach: RuntimePairingReach = 'network' + ): DeviceEntry { + return this.createAndPersistDevice(this.devices, name, scope, pairingReach) } private createAndPersistDevice( existingDevices: DeviceEntry[], name: string, - scope: DeviceScope + scope: DeviceScope, + pairingReach: RuntimePairingReach ): DeviceEntry { const entry: DeviceEntry = { deviceId: randomUUID(), @@ -67,7 +76,8 @@ export class DeviceRegistry { token: randomBytes(24).toString('hex'), scope, pairedAt: Date.now(), - lastSeenAt: 0 + lastSeenAt: 0, + pairingReach } const nextDevices = [...existingDevices, entry] // Why: a credential is not valid until its durable registry write succeeds. @@ -82,12 +92,32 @@ export class DeviceRegistry { // copy-button flow that encourages regeneration) leaves an orphaned token // forever. Returns an existing never-scanned entry if present; otherwise // mints a new one and drops any stale pending entries. - getOrCreatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { + getOrCreatePendingDevice( + name: string, + scope: DeviceScope = 'mobile', + pairingReach: RuntimePairingReach = 'network' + ): DeviceEntry { const existing = this.devices.find((d) => d.lastSeenAt === 0 && d.scope === scope) if (existing) { - return existing + // Why: the same pending token can be re-advertised at a broader reach; widen it but never narrow it, + // or a link already handed out for off-host use would stop being served after the next launch. + return pairingReach === 'network' && existing.pairingReach === 'this-computer' + ? this.setPairingReach(existing, 'network') + : existing } - return this.addDevice(name, scope) + return this.addDevice(name, scope, pairingReach) + } + + private setPairingReach(existing: DeviceEntry, pairingReach: RuntimePairingReach): DeviceEntry { + const updated: DeviceEntry = { ...existing, pairingReach } + const nextDevices = this.devices.map((device) => + device.deviceId === existing.deviceId ? updated : device + ) + // Why: persist before the memory swap so a failed write cannot leave the bind decision reading a + // reach that never reached disk. + this.save(nextDevices) + this.devices = nextDevices + return updated } // Why: explicit rotation path for "Regenerate QR" — invalidates any @@ -96,9 +126,13 @@ export class DeviceRegistry { // this, getOrCreatePendingDevice keeps returning the same token forever // until a phone actually pairs, so users have no way to revoke a leaked // pre-pairing token. - rotatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { + rotatePendingDevice( + name: string, + scope: DeviceScope = 'mobile', + pairingReach: RuntimePairingReach = 'network' + ): DeviceEntry { const retainedDevices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope) - return this.createAndPersistDevice(retainedDevices, name, scope) + return this.createAndPersistDevice(retainedDevices, name, scope, pairingReach) } removeDevice(deviceId: string): boolean { @@ -197,7 +231,10 @@ export class DeviceRegistry { scope: device.scope === 'runtime' ? 'runtime' : 'mobile', relayBinding: validRelayBinding(device.relayBinding, device.deviceId), mobilePairingConnectionMode: - device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic' + device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic', + // Why: registries written before this field existed only ever held network-reach grants (phones and + // LAN links), so a missing value must keep binding every interface on reconnect. + pairingReach: device.pairingReach === 'this-computer' ? 'this-computer' : 'network' })) } catch { this.devices = [] diff --git a/src/main/runtime/pairing-endpoint.test.ts b/src/main/runtime/pairing-endpoint.test.ts index d43c95d7873..5fcebe0afd8 100644 --- a/src/main/runtime/pairing-endpoint.test.ts +++ b/src/main/runtime/pairing-endpoint.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolveAdvertisedPairingEndpoint } from './pairing-endpoint' +import { + resolveAdvertisedPairingEndpoint, + resolveAdvertisedPairingHostname +} from './pairing-endpoint' import { PAIRING_OFFER_VERSION, PairingOfferSchema } from '../../shared/mobile-relay-pairing-offer' import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../../shared/mobile-pairing-protocol-limits' import { parseManualNetworkAddress } from '../../shared/network/manual-address' @@ -100,3 +103,31 @@ describe('resolveAdvertisedPairingEndpoint', () => { expect(PairingOfferSchema.safeParse(offer(aboveLimit)).success).toBe(false) }) }) + +describe('resolveAdvertisedPairingHostname', () => { + // Why: the loopback/off-host decision must read the host a link will advertise, not the raw string the + // user typed — `127.0.0.1:8443` and `ws://127.0.0.1:6768` both publish a loopback host. + it.each([ + ['127.0.0.1', '127.0.0.1'], + ['127.0.0.1:8443', '127.0.0.1'], + ['localhost', 'localhost'], + ['localhost:8443', 'localhost'], + ['::1', '::1'], + ['[::1]:6768', '::1'], + ['ws://127.0.0.1:6768', '127.0.0.1'], + ['http://localhost:6768/web-index.html', 'localhost'], + ['100.64.1.20', '100.64.1.20'], + ['192.168.1.5:6768', '192.168.1.5'], + ['desktop', 'desktop'], + ['wss://proxy.example.test:8443/orca', 'proxy.example.test'] + ])('reads the advertised host of %s', (input, hostname) => { + expect(resolveAdvertisedPairingHostname(input)).toBe(hostname) + }) + + it.each([null, undefined, '', ' ', 'host.example.test:', 'ftp://proxy.example.test'])( + 'returns null for the unusable address %j', + (input) => { + expect(resolveAdvertisedPairingHostname(input)).toBeNull() + } + ) +}) diff --git a/src/main/runtime/pairing-endpoint.ts b/src/main/runtime/pairing-endpoint.ts index c7af166f191..75ef7ae68c6 100644 --- a/src/main/runtime/pairing-endpoint.ts +++ b/src/main/runtime/pairing-endpoint.ts @@ -40,6 +40,23 @@ export function resolveAdvertisedPairingEndpoint( return valid(formatWebSocketUrl(endpoint)) } +// Why: callers that must reason about WHERE a link points (loopback vs off-host) need the hostname the +// offer will advertise, not the raw string the user typed — `127.0.0.1:8443`, `[::1]:6768` and +// `ws://127.0.0.1:6768` all advertise a loopback host but none of them parse as one on their own. +export function resolveAdvertisedPairingHostname( + advertisedAddress: string | null | undefined +): string | null { + const override = advertisedAddress?.trim() + if (!override) { + return null + } + if (override.includes('://')) { + const normalized = normalizePairingUrl(override) + return normalized ? unbracketIpv6(new URL(normalized).hostname) : null + } + return parseHostOverride(override)?.hostname ?? null +} + function resolveFullUrl(value: string): PairingEndpointResolution { const endpoint = normalizePairingUrl(value) return endpoint ? valid(endpoint) : invalid() diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 609079305c5..55d03efbd50 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -5616,6 +5616,149 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { } }) + it('stays on loopback at startup after a "This computer only" grant has connected', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why: the local web client authenticating marks its grant lastSeenAt > 0 like any other socket, so a + // blanket "any connected device" widen republished the runtime on every interface one launch later — + // exactly what the user declined by picking "This computer only". + const registry = new DeviceRegistry(userDataPath) + const device = registry.getOrCreatePendingDevice('Runtime local', 'runtime', 'this-computer') + registry.updateLastSeen(device.deviceId) + + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + } finally { + await server.stop() + } + }) + + it('binds all interfaces at startup for a connected device paired before pairingReach existed', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why: registries written by older desktops only ever held network-reach grants; a missing field must + // keep the reconnect widen or an already-paired phone would be stranded by the upgrade. + const legacyDevice = { + deviceId: 'legacy-device', + name: 'Legacy phone', + token: 'legacy-token', + scope: 'mobile', + pairedAt: Date.now(), + lastSeenAt: Date.now() + } + await writeFile( + join(userDataPath, DEVICE_REGISTRY_FILENAME), + JSON.stringify([legacyDevice]), + 'utf-8' + ) + + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + } finally { + await server.stop() + } + }) + + it('upgrades a reused pending grant to network reach so its link survives a relaunch', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + let deviceId: string + try { + const local = server.createPairingOffer({ + address: '127.0.0.1', + scope: 'runtime', + reach: 'this-computer' + }) + expect(local.available).toBe(true) + // Why: without `rotate` the same pending token is re-advertised, now for off-host reach. The mark must + // widen with it — keeping it this-computer would leave the LAN link unserved after the next launch. + const network = server.createPairingOffer({ + address: '100.64.1.20', + scope: 'runtime', + reach: 'network' + }) + expect(network.available).toBe(true) + deviceId = network.available ? network.deviceId : '' + expect(deviceId).toBe(local.available ? local.deviceId : '') + server.getDeviceRegistry()?.updateLastSeen(deviceId) + } finally { + await server.stop() + } + + const relaunched = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await relaunched.start() + try { + expect(wsTransportOf(relaunched)?.resolvedHost).toBe('0.0.0.0') + } finally { + await relaunched.stop() + } + }) + + it('keeps the pinned port when a later widen tears down a live loopback client', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const loopbackPort = wsTransportOf(server)!.resolvedPort + // Why: a "This computer only" link never widens, so unlike before, a local client can already be + // connected when a later LAN offer opts in. The rebind terminates it (ws cannot move a listener), so + // the port must be reused or the already-issued local link could never reconnect. + const client = new WebSocket(`ws://127.0.0.1:${loopbackPort}`) + await new Promise((resolve, reject) => { + client.once('open', () => resolve()) + client.once('error', reject) + }) + const closed = new Promise((resolve) => client.once('close', () => resolve())) + + await server.ensureNetworkExposure() + await closed + + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + expect(wsTransportOf(server)?.resolvedPort).toBe(loopbackPort) + + const reconnected = new WebSocket(`ws://127.0.0.1:${loopbackPort}`) + await new Promise((resolve, reject) => { + reconnected.once('open', () => resolve()) + reconnected.once('error', reject) + }) + reconnected.close() + } finally { + await server.stop() + } + }) + it('keeps the same MobileSocketWiring instance across a pairing widen (relay capture stays valid)', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const server = new OrcaRuntimeRpcServer({ diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f6274c3a996..7056f4bfe8f 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -30,6 +30,7 @@ import { } from './rpc/mobile-socket-wiring' import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import { mobileRelayMintFailureFromUnknown, type MobileRelayMintFailure @@ -661,6 +662,9 @@ export class OrcaRuntimeRpcServer { name?: string rotate?: boolean scope?: DeviceScope + // Why: STA-2370 — recorded on the grant so a "This computer only" client reconnecting cannot make the + // next launch bind every interface. Defaults to network reach, which is what every other caller means. + reach?: RuntimePairingReach }): | PairingOfferUnavailable | { @@ -697,9 +701,10 @@ export class OrcaRuntimeRpcServer { const scope = args.scope ?? 'runtime' let device: DeviceEntry try { + const reach = args.reach ?? 'network' device = args.rotate - ? this.deviceRegistry.rotatePendingDevice(deviceName, scope) - : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope) + ? this.deviceRegistry.rotatePendingDevice(deviceName, scope, reach) + : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope, reach) } catch (error) { console.error('[runtime] Failed to persist pairing credential:', error) return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) @@ -1228,13 +1233,17 @@ export class OrcaRuntimeRpcServer { // Why: STA-2370 — a desktop with no previously-connected device stays on loopback until the user // explicitly pairs; `orca serve`/E2E (exposeNetworkByDefault) and a reconnecting paired device bind wide. + // A grant minted for "This computer only" is excluded: its client is a browser on this machine, so + // counting it would republish the runtime on every interface one restart after the user declined that. private resolveInitialWebSocketBindHost(): string { if (this.exposeNetworkByDefault) { return WS_BIND_HOST_ALL_INTERFACES } - const hasConnectedDevice = - this.deviceRegistry?.listDevices().some((device) => device.lastSeenAt > 0) ?? false - return hasConnectedDevice ? WS_BIND_HOST_ALL_INTERFACES : WS_BIND_HOST_LOOPBACK + const hasConnectedNetworkDevice = + this.deviceRegistry + ?.listDevices() + .some((device) => device.lastSeenAt > 0 && device.pairingReach !== 'this-computer') ?? false + return hasConnectedNetworkDevice ? WS_BIND_HOST_ALL_INTERFACES : WS_BIND_HOST_LOOPBACK } // Why: builds and starts a WS transport bound to `host`, wiring the session-scoped mobile socket @@ -1340,9 +1349,10 @@ export class OrcaRuntimeRpcServer { } // Why: STA-2370 — widen the loopback listener to all interfaces so a freshly generated pairing - // offer's advertised LAN endpoint is reachable. Idempotent and only ever runs on the first opt-in - // (before any device has connected), so no live socket is disrupted; the resolved port is reused so - // an already-issued endpoint stays valid. + // offer's advertised LAN endpoint is reachable. Idempotent, but it is no longer confined to the first + // pairing action: a "This computer only" link never widens, so live loopback clients can already be + // connected when a later LAN/QR offer opts in. Rebinding terminates them (ws cannot move a listener), + // so the resolved port is reused — already-issued endpoints stay valid and clients reconnect in place. async ensureNetworkExposure(): Promise { if ( !this.enableWebSocket || diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 859be396e2e..c0c57731e1e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -49,6 +49,7 @@ import type { } from '../shared/terminal-render-desync-evidence' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../shared/runtime-pairing-reach' import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { @@ -3650,7 +3651,11 @@ export type PreloadApi = { { ok: true } | { ok: false; reason: 'cancelled' | 'failed' | 'unsupported' } > openWindowsNetworkSettings: () => Promise - getRuntimePairingUrl: (args?: { address?: string; rotate?: boolean }) => Promise< + getRuntimePairingUrl: (args?: { + address?: string + rotate?: boolean + reach?: RuntimePairingReach + }) => Promise< | { available: false; reason?: 'network_exposure_failed'; guidance?: string } | { available: true diff --git a/src/preload/index.ts b/src/preload/index.ts index 13a450bce83..d54af396aca 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,7 @@ import type { } from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../shared/runtime-pairing-reach' import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { @@ -4651,6 +4652,9 @@ const api = { getRuntimePairingUrl: (args?: { address?: string rotate?: boolean + // Why: the widen is one-way and host-wide, so main must gate it on the reach the user picked, not + // on how the typed address happens to look (a Custom loopback may front an SSH tunnel). + reach?: RuntimePairingReach }): Promise< | { available: false; reason?: 'network_exposure_failed'; guidance?: string } | { diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx index f0bb4ba08df..0b32a439dbd 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx @@ -7,15 +7,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ listNetworkInterfaces: vi.fn(), - listRuntimeAccessGrants: vi.fn() + listRuntimeAccessGrants: vi.fn(), + getRuntimePairingUrl: vi.fn() })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) vi.mock('./RuntimeAccessGrantList', () => ({ RuntimeAccessGrantList: () => null })) vi.mock('./RuntimePairingGeneratorForm', () => ({ - RuntimePairingGeneratorForm: (props: { selectedAddress: string }) => ( -
{props.selectedAddress}
+ RuntimePairingGeneratorForm: (props: { selectedAddress: string; onGenerate: () => void }) => ( +
+
{props.selectedAddress}
+ +
) })) @@ -33,12 +39,20 @@ describe('RuntimePairingUrlGenerator', () => { runtimePairingLinkCache.runtimePairingDeviceId = null mocks.listNetworkInterfaces.mockReset() mocks.listRuntimeAccessGrants.mockReset().mockResolvedValue({ grants: [] }) + mocks.getRuntimePairingUrl.mockReset().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#runtime', + webClientUrl: 'http://127.0.0.1:6768/web-index.html?pairing=runtime', + endpoint: 'ws://127.0.0.1:6768', + deviceId: 'runtime-1' + }) Object.defineProperty(window, 'api', { configurable: true, value: { mobile: { listNetworkInterfaces: mocks.listNetworkInterfaces, - listRuntimeAccessGrants: mocks.listRuntimeAccessGrants + listRuntimeAccessGrants: mocks.listRuntimeAccessGrants, + getRuntimePairingUrl: mocks.getRuntimePairingUrl } } }) @@ -67,4 +81,28 @@ describe('RuntimePairingUrlGenerator', () => { expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125') ) }) + + // Why: the main process gates the one-way network widen on the declared reach, so dropping it (as the + // component used to) leaves main guessing from the address string — "This computer only" then widened, + // and a Custom loopback tunnel front-end would not. + it.each([ + ['local' as const, '127.0.0.1', 'this-computer'], + ['another' as const, '100.76.32.125', 'network'], + ['custom' as const, '127.0.0.1:8443', 'network'] + ])('sends the %s reach with the address', async (intent, address, reach) => { + runtimePairingLinkCache.intent = intent + runtimePairingLinkCache.selectedAddress = address + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'tailscale0', address: '100.76.32.125' }] + }) + + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + + screen.getByRole('button', { name: 'Generate' }).click() + + await waitFor(() => + expect(mocks.getRuntimePairingUrl).toHaveBeenCalledWith({ address, rotate: true, reach }) + ) + }) }) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index 54c75c7ac92..c6d927de593 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -11,6 +11,7 @@ import { cacheGeneratedRuntimePairingLink, clearGeneratedRuntimePairingLink, runtimePairingLinkCache, + runtimePairingReachForIntent, selectRuntimePairingIntent, type RuntimePairingIntent, type RuntimePairingUrlGeneratorProps @@ -187,7 +188,10 @@ export function RuntimePairingUrlGenerator({ try { const result = await window.api.mobile.getRuntimePairingUrl({ address, - rotate: true + rotate: true, + // Why: main gates the one-way network widen on this, so the declared choice must travel with the + // address — the address alone cannot tell "This computer only" from a loopback tunnel front-end. + reach: runtimePairingReachForIntent(intent) }) if (!result.available) { clearGeneratedUrls() diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.ts index 0b3a1230fb9..ff777fc72fe 100644 --- a/src/renderer/src/components/settings/runtime-pairing-link-state.ts +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.ts @@ -1,7 +1,15 @@ +import type { RuntimePairingReach } from '../../../../shared/runtime-pairing-reach' + export const RUNTIME_PAIRING_LOOPBACK_ADDRESS = '127.0.0.1' export type RuntimePairingIntent = 'another' | 'local' | 'custom' +// Why: only "This computer only" declines off-host reach. Custom is the SSH-tunnel/reverse-proxy field, so +// even a loopback-looking custom address (`127.0.0.1:8443`) needs the listener open behind the tunnel. +export function runtimePairingReachForIntent(intent: RuntimePairingIntent): RuntimePairingReach { + return intent === 'local' ? 'this-computer' : 'network' +} + export type RuntimePairingUrlGeneratorProps = { framed?: boolean showHeader?: boolean diff --git a/src/shared/runtime-pairing-reach.ts b/src/shared/runtime-pairing-reach.ts new file mode 100644 index 00000000000..0f7f0315416 --- /dev/null +++ b/src/shared/runtime-pairing-reach.ts @@ -0,0 +1,5 @@ +// Why: STA-2370 — widening the runtime listener to every interface is one-way for the life of the process +// and persists across launches, so it must follow the reach the user picked, not the shape of the address +// they typed: a Custom `127.0.0.1:8443` is usually an SSH tunnel or reverse proxy that still needs off-host +// reach, while "This computer only" must never publish the runtime beyond loopback. +export type RuntimePairingReach = 'this-computer' | 'network' From ed7849eb7b9432089e910873582bb96bfc7fa48f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:25:50 -0700 Subject: [PATCH 048/101] fix(worktrees): stop silently switching existing Windows setup scripts to Git Bash (#12406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worktrees): stop silently switching existing Windows setup scripts to Git Bash #6967 derived the Windows setup-runner shell from `terminalWindowsShell`. On upgrade, any Windows user whose terminal preference resolved to Git Bash had their existing `orca.yaml` setup script (and issue command) handed to bash instead of cmd.exe. Scripts authored against the cmd runner — `copy`, `xcopy`, `set VAR=value`, `if errorlevel 1`, `%VAR%`, backslash paths — broke with no migration and no warning, and the failure looked like Orca broke the project. The conflation is also wrong in the steady state: a terminal preference is per-user, so two people on the same repo got different interpreters for the same orca.yaml and no project could write a setup script that worked for all of its Windows contributors. The interpreter is now a property of the script, declared the standard way: a leading `#!` line. Native Windows keeps the historical `.cmd` runner unless the script declares a POSIX shell, so no existing script changes behavior. `resolveSetupRunnerShell` keeps its role as the feasibility gate — a bash runner still requires the terminal to resolve to Git Bash, since the launch command is typed into that shell and uses MSYS `/c/...` paths. `buildWindowsRunnerScript` now drops a leading `#!` line rather than `call`ing it, so a declared-bash script that falls back to cmd (Git Bash missing) fails on a real setup line instead of aborting on errorlevel at line one. WSL worktrees, POSIX platforms, and SSH hosts are untouched. * fix(worktrees): keep the cmd setup runner launchable from a Git Bash pane Adversarial review of this PR found that pinning the runner format per script reopened issue #6896 one layer down. - `WorktreeSetupLaunch.shell` had been redefined to mean "the format the runner file was written in". `resolveSetupRunnerCommand` consumes it as "the shell that types the launch command", so a Git Bash terminal with a batch setup script produced `cmd.exe /c "C:\...\setup-runner.cmd"` typed into a bash pane, where MSYS rewrites the `/c` switch into a drive path: cmd opens interactively and setup never runs. `shell` is the terminal's family again; the runner file's .cmd/.sh extension carries the format, and a batch runner launched from a POSIX pane reuses the existing PowerShell ProcessStartInfo launcher. - The cmd runner dropped a leading `#!` line and ran the rest as batch, so a bash script reaching cmd (PowerShell/cmd terminal, or any SSH-to-Windows host) got its interpreter-agnostic prefix executed before failing mid-way. It now prints why and exits 1 without running anything. - A `#!` line's option flags were discarded: `#!/usr/bin/env -S bash -euo pipefail` lost pipefail because the runner is launched as `bash `. The generated posix runner now replays declared flags via `set` and drops the duplicate interpreter line. - Docs cover the per-user setup command in repository hook settings, which goes through the same `#!` rule, and describe what the `#!` line does and does not select. Tests: composed launch command for a POSIX pane + cmd runner (hooks, shared runner command, setup sequencing gate, observed-setup signal), the cmd runner's shebang refusal, and shebang flag replay. Each fails with the source reverted. * fix(worktrees): replay only real `set` flags and keep the gate in the pane's shell Two round-2 review findings: - `#!/bin/bash -l` replayed `set -l`, which exits 2 and aborted the runner under its own `set -e` before a single setup line ran (all platforms). Only the flags `set` documents are replayed now; a bare `-o` with no option name is dropped instead of dumping the shell-option table. - The wait-for-setup gate picked its language from the runner file, so a batch runner launched from a Git Bash pane got the PowerShell gate while the agent startup command was already POSIX-quoted — `Invoke-Expression` cannot parse `'\''`. The gate now follows the pane; the runner still launches through the ProcessStartInfo launcher, never through bash. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .gitignore | 1 + AGENTS.md | 1 + docs/reference/windows-setup-shell.md | 86 ++++++++++ src/main/hooks-runner.test.ts | 56 ++++++- src/main/hooks.test.ts | 147 ++++++++++++++++-- src/main/hooks.ts | 64 ++++++-- .../setup-completion-signal.test.ts | 13 ++ src/shared/setup-agent-sequencing.test.ts | 34 ++++ src/shared/setup-agent-sequencing.ts | 13 +- src/shared/setup-runner-command.test.ts | 45 ++++++ src/shared/setup-runner-command.ts | 24 ++- src/shared/setup-script-shebang.test.ts | 99 ++++++++++++ src/shared/setup-script-shebang.ts | 100 ++++++++++++ 13 files changed, 654 insertions(+), 29 deletions(-) create mode 100644 docs/reference/windows-setup-shell.md create mode 100644 src/shared/setup-script-shebang.test.ts create mode 100644 src/shared/setup-script-shebang.ts diff --git a/.gitignore b/.gitignore index 2918ae32a69..b1de9292f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ docs/** !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md !docs/reference/relay-grace-time-reconfiguration.md +!docs/reference/windows-setup-shell.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/AGENTS.md b/AGENTS.md index bfb1617f8ea..6bc54a6f5c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`. - **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms. - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. +- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md). - **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. ## SSH Use Case diff --git a/docs/reference/windows-setup-shell.md b/docs/reference/windows-setup-shell.md new file mode 100644 index 00000000000..6c93185b602 --- /dev/null +++ b/docs/reference/windows-setup-shell.md @@ -0,0 +1,86 @@ +# Windows setup-runner shell + +On native Windows, Orca writes the `orca.yaml` setup script (and the issue command) to a generated +runner file and types a launch command into a terminal. The runner is a **`.cmd` batch file by +default**, exactly as it has been since setup hooks shipped. + +A script opts into bash by starting with a `#!` interpreter line: + +```yaml +scripts: + setup: | + #!/usr/bin/env bash + [ -f .env ] || cp .env.example .env + pnpm install +``` + +Without that line the script keeps running under `cmd.exe`: + +```yaml +scripts: + setup: | + copy .env.example .env + xcopy /E assets dist +``` + +## Why the script declares it, not the terminal preference + +`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the +language a project's setup script is written in. Deriving the runner from it had two consequences: + +- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`, + `xcopy`, `set VAR=value`, and `if errorlevel 1` stopped working. +- Two people on the same repo got different interpreters for the same `orca.yaml`, so no project + could write a setup script that worked for all of its Windows contributors. + +A `#!` line is per-project, explicit, and identical for everyone who checks the repo out. + +The same rule applies to the per-user setup command in **Settings → repository hooks** +(`repo.hookSettings.scripts.setup`): it is merged into the same script that reaches the runner, so a +POSIX one-liner stored there needs its own `#!` line to run under bash on Windows. + +## What the `#!` line does and does not select + +The generated runner is always executed by bash (`bash `; Git Bash on native Windows), on +every platform. The `#!` line therefore does two things: + +- It declares the script is written for a POSIX shell, which is what selects the bash runner. +- Its option flags are replayed with `set`, so `#!/usr/bin/env -S bash -euo pipefail` really does + get `pipefail`. Without that replay the flags would be silently dropped, because `bash ` + never parses the interpreter line. Only the flags `set` itself accepts + (`[--abefhkmnptuvxBCHP] [-o option]`) are replayed; invocation-only ones such as `-l` are + dropped, because `set -l` exits 2 and would abort the runner before its first line. + +The interpreter name itself is not honored beyond "is this a POSIX shell": `#!/bin/sh` and +`#!/bin/zsh` scripts run under bash, exactly as they already did on macOS and Linux. + +## Requirements for the bash runner + +A `#!` line only takes effect when Orca can actually launch bash from the configured terminal — the +terminal shell must resolve to Git Bash (`resolveWindowsGitBashShellPath`). The generated runner +uses MSYS `/c/...` paths, which Cygwin and the WSL shim do not accept, and the launch command is +typed into whatever shell the terminal opened with. + +When bash is not available (a PowerShell/cmd terminal, or an SSH-to-Windows host, which always uses +the remote's `.cmd` runner) the `#!` script is **not** executed under cmd. The generated `.cmd` +runner prints why and exits 1, because running the interpreter-agnostic prefix of a bash script +(`pnpm install`, `git submodule update`) and only failing at the first bash-only line leaves a +half-set-up worktree that looks finished. + +## Launching a `.cmd` runner from a Git Bash terminal + +The runner format and the shell that types the launch command are independent: a Git Bash terminal +with a batch-syntax setup script gets a `.cmd` runner launched from a bash pane. `cmd.exe /c +"C:\..."` cannot be used there — MSYS rewrites the bare `/c` switch into a drive path, so cmd opens +interactively and the runner never executes (issue #6896). Those launches reuse the PowerShell +`ProcessStartInfo` launcher (`buildWindowsCmdRunnerDelayedLaunchCommand`), which carries the switch +and the runner path outside the command line. `WorktreeSetupLaunch.shell` therefore describes the +launching pane; the runner file's `.cmd`/`.sh` extension describes the format. + +The `wait-for-setup` gate follows the same split. The pane types the gate and already quoted the +agent startup command for itself, so a `.cmd` runner launched from a Git Bash pane still gets the +bash gate — PowerShell's `Invoke-Expression` cannot parse POSIX `'\''` escaping. The gate wraps the +same `ProcessStartInfo` launcher, so the batch runner is never handed to bash. + +WSL worktrees and non-Windows platforms are unaffected: they always use the bash runner. SSH hosts +choose their runner from the remote path format, never from local Windows preferences. diff --git a/src/main/hooks-runner.test.ts b/src/main/hooks-runner.test.ts index 79827207aba..5ca5903d66f 100644 --- a/src/main/hooks-runner.test.ts +++ b/src/main/hooks-runner.test.ts @@ -1,3 +1,8 @@ +import type * as NodeChildProcess from 'node:child_process' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + import type { Repo } from '../shared/types' import { describe, expect, it, vi } from 'vitest' @@ -94,7 +99,7 @@ describe('createSetupRunnerScript', () => { const result = createSetupRunnerScript( { ...makeRepo(), path: 'C:\\Users\\jinwo\\git\\orca' }, 'C:\\repo\\feature', - 'pnpm install', + '#!/usr/bin/env bash\npnpm install', undefined, { family: 'posix' } ) @@ -186,6 +191,55 @@ describe('createSetupRunnerScript', () => { expect(runner).toContain('call echo hello!world!') }) + it('refuses to run a shebang script as a batch command', async () => { + const { buildWindowsRunnerScript } = await import('./hooks') + + // Regression: a POSIX script reaching the cmd runner (no Git Bash terminal, or an SSH + // Windows host) must not have its interpreter-agnostic prefix executed under cmd — + // `pnpm install` would run and only a later bash-only line would fail, leaving the + // worktree half set up. + const runner = buildWindowsRunnerScript('#!/usr/bin/env bash\npnpm install\nsource .env') + + expect(runner).not.toContain('call pnpm install') + expect(runner).not.toContain('call source .env') + expect(runner).toContain('needs a POSIX shell') + expect(runner).toContain('exit /b 1') + }) + + it('keeps a `#` comment that is not an interpreter line', async () => { + const { buildWindowsRunnerScript } = await import('./hooks') + + const runner = buildWindowsRunnerScript('# not a shebang\nrem still batch') + + expect(runner).toContain('call # not a shebang') + expect(runner).toContain('call rem still batch') + }) + + it.skipIf(process.platform === 'win32')( + 'runs a script whose shebang carries invocation-only bash flags', + async () => { + // Regression: `set` rejects `-l`/`-s`/`-i` with exit 2, and the runner's own `set -e` turns + // that into an aborted setup before the first user line. + const { buildPosixRunnerScript } = await import('./hooks') + const { mkdtempSync, rmSync, writeFileSync } = await vi.importActual('node:fs') + const { execFileSync } = await vi.importActual('node:child_process') + + const dir = mkdtempSync(join(tmpdir(), 'orca-posix-runner-')) + try { + const runnerPath = join(dir, 'setup-runner.sh') + writeFileSync( + runnerPath, + buildPosixRunnerScript('#!/bin/bash -l\necho SETUP_RAN\n'), + 'utf-8' + ) + + expect(execFileSync('bash', [runnerPath], { encoding: 'utf-8' })).toContain('SETUP_RAN') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + ) + it('derives ORCA_WORKSPACE_NAME from a POSIX worktree path', async () => { const originalPlatform = process.platform diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index b51a38e3321..c39ea86cf2f 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -1255,7 +1255,7 @@ describe('createSetupRunnerScript', () => { } }) as unknown as Repo - it('writes POSIX setup runners for Git Bash on native Windows paths', async () => { + it('writes POSIX setup runners for shebang-declared scripts on native Windows paths', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.sh\n') const fs = await import('node:fs') @@ -1271,7 +1271,7 @@ describe('createSetupRunnerScript', () => { const result = createSetupRunnerScript( makeRepo(), 'C:\\repo-worktree', - 'pnpm install\r\nnpm run build', + '#!/usr/bin/env bash\r\npnpm install\r\nnpm run build', undefined, { family: 'posix' } ) @@ -1295,6 +1295,108 @@ describe('createSetupRunnerScript', () => { } }) + it('keeps batch setup scripts on cmd.exe when the terminal is Git Bash', async () => { + // Regression (#6967): a Git Bash terminal preference used to hand pre-existing + // batch setup scripts to bash, where `copy`/`xcopy`/`if errorlevel` do not exist. + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + writeFileSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'copy .env.example .env\r\nxcopy /E assets dist', + undefined, + { family: 'posix' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\setup-runner.cmd', + expect.stringContaining('call copy .env.example .env\r\n'), + 'utf-8' + ) + expect(result).toMatchObject({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd', + // Why: `shell` is the pane that types the launch command — still Git Bash here. The + // runner's own .cmd extension is what says the file is batch. + shell: { family: 'posix' } + }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('hands a Git Bash pane a launch command MSYS cannot rewrite for a cmd runner', async () => { + // Regression (#6896): `cmd.exe /c "C:\...\setup-runner.cmd"` typed into Git Bash has its + // `/c` switch rewritten into a drive path, so cmd opens interactively and setup never runs. + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n') + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const { buildSetupRunnerCommand } = await import('../shared/setup-runner-command') + const result = createSetupRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'copy .env.example .env', + undefined, + { family: 'posix' } + ) + + const command = buildSetupRunnerCommand(result.runnerScriptPath, 'windows', result.shell) + + expect(command).not.toContain('cmd.exe /c') + // Why: the batch runner must never be handed to bash either. + expect(command).not.toContain('bash ') + expect(command).toContain('powershell.exe') + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('replays interpreter flags declared on the shebang line', async () => { + // Regression: the runner is launched as `bash `, so `-euo pipefail` on the script's + // own `#!` line never reaches the interpreter unless the runner re-applies it. + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.sh\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + writeFileSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + createSetupRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + '#!/usr/bin/env -S bash -euo pipefail\nmake build | tee build.log', + undefined, + { family: 'posix' } + ) + + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\setup-runner.sh', + '#!/usr/bin/env bash\nset -e\nset -euo pipefail\nmake build | tee build.log\n', + 'utf-8' + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('preserves cmd.exe setup runner semantics for configured cmd users', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n') @@ -1409,7 +1511,7 @@ describe('createIssueCommandRunnerScript', () => { hookSettings: { mode: 'auto', scripts: { setup: '', archive: '' } } }) as unknown as Repo - it('writes a POSIX issue-command runner when setup resolves to Git Bash', async () => { + it('writes a POSIX issue-command runner when a shebang declares bash and setup resolves to Git Bash', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.sh\n') const fs = await import('node:fs') @@ -1418,6 +1520,37 @@ describe('createIssueCommandRunnerScript', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + '#!/usr/bin/env bash\ngh issue view 42', + undefined, + { family: 'posix' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/issue-command-runner.sh'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\issue-command-runner.sh', + '#!/usr/bin/env bash\nset -e\ngh issue view 42\n', + 'utf-8' + ) + expect(result.shell).toEqual({ family: 'posix' }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps a plain issue command on the cmd runner under a Git Bash terminal', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.cmd\n') + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { const { createIssueCommandRunnerScript } = await import('./hooks') const result = createIssueCommandRunnerScript( @@ -1429,14 +1562,10 @@ describe('createIssueCommandRunnerScript', () => { ) expect(gitExecFileSyncMock).toHaveBeenCalledWith( - ['rev-parse', '--git-path', 'orca/issue-command-runner.sh'], + ['rev-parse', '--git-path', 'orca/issue-command-runner.cmd'], { cwd: 'C:\\repo-worktree' } ) - expect(writeFileSyncMock).toHaveBeenCalledWith( - 'C:\\repo\\.git\\orca\\issue-command-runner.sh', - '#!/usr/bin/env bash\nset -e\ngh issue view 42\n', - 'utf-8' - ) + // Why: the runner file is batch (.cmd) while the pane that launches it is still Git Bash. expect(result.shell).toEqual({ family: 'posix' }) } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index ad73003778f..21b56b8a597 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -9,6 +9,12 @@ import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-star import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard' import { parseOrcaYaml } from '../shared/orca-yaml' import { nativeWindowsPathToPosixShellPath } from '../shared/setup-runner-command' +import { + isShebangLine, + parseSetupScriptShebang, + scriptDeclaresPosixShell, + stripLeadingShebangLine +} from '../shared/setup-script-shebang' import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' import { resolveWindowsGitBashShellPath } from './git-bash' import { gitExecFileSync, promptGuardShellEnv } from './git/runner' @@ -397,13 +403,32 @@ function getHookWslContext( } } +// Why: cmd cannot run a POSIX script, and executing its interpreter-agnostic prefix (`pnpm +// install`, `git submodule update`) before dying on the first bash-only line is worse than not +// starting: half-applied setup looks like a working worktree. +const WINDOWS_RUNNER_SHEBANG_REFUSAL = [ + 'echo Orca setup: this script starts with a "#!" interpreter line, so it needs a POSIX shell. 1>&2', + 'echo Orca setup: this worktree runs setup through cmd.exe, which cannot execute it. 1>&2', + 'echo Orca setup: set the Windows terminal shell to Git Bash, or rewrite the script in cmd syntax. 1>&2', + 'exit /b 1', + '' +].join('\r\n') + export function buildWindowsRunnerScript(script: string): string { // Why: launchers invoke this runner under `cmd /v:on`, and EnableExtensions does not reset an // inherited delayed-expansion state — without this, every `!` in a user setup line is eaten. - let runnerScript = '@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n' + const header = '@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n' + let runnerScript = header + let isFirstLine = true for (const rawLine of iterateLfScriptLines(script)) { const command = rawLine.trim() + if (isFirstLine) { + isFirstLine = false + if (isShebangLine(command)) { + return `${header}${WINDOWS_RUNNER_SHEBANG_REFUSAL}` + } + } if (!command) { runnerScript += '\r\n' continue @@ -462,7 +487,15 @@ export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record< } export function buildPosixRunnerScript(script: string): string { - return `#!/usr/bin/env bash\nset -e\n${normalizeCrlfScriptLineEndings(script)}\n` + // Why: the runner is always launched as `bash `, so flags on the script's own `#!` line + // are never seen by the interpreter — replay them through `set` or a script that asked for + // `-euo pipefail` silently loses pipefail, and drop the now-duplicate interpreter line. + const shebang = parseSetupScriptShebang(script) + const declaredOptions = shebang?.shellOptions.length + ? `set ${shebang.shellOptions.join(' ')}\n` + : '' + const body = shebang ? stripLeadingShebangLine(script) : script + return `#!/usr/bin/env bash\nset -e\n${declaredOptions}${normalizeCrlfScriptLineEndings(body)}\n` } function normalizeCrlfScriptLineEndings(script: string): string { @@ -500,8 +533,8 @@ export function createIssueCommandRunnerScript( script: command, runnerBaseName: 'issue-command-runner', runtimeTarget: getHookRuntimeTarget(projectRuntime), - // Why: issue commands run in the same terminal as setup, so a Git Bash setup - // runner must not be paired with a cmd issue runner in one session. + // Why: issue commands share the setup session's shell resolution; the runner still + // needs its own `#!` line to be treated as bash. setupShell }) } @@ -528,11 +561,21 @@ function createWorktreeRunnerScript(args: { // Why: WSL worktrees are Linux fs even though process.platform is 'win32'; use bash for WSL, .cmd for native Windows. const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro) const nativeWindowsWorktree = process.platform === 'win32' && !wslWorktree + // Why: the terminal-shell preference says nothing about the language a project's script is + // written in, and every pre-existing Windows script was authored against the cmd runner. Only a + // `#!` line opts a script into bash, so the same orca.yaml runs identically for every Windows + // user of the repo instead of following whichever terminal each of them happens to prefer. const runnerShell: SetupRunnerShell = nativeWindowsWorktree - ? (setupShell ?? { family: 'cmd' }) + ? setupShell?.family === 'posix' && scriptDeclaresPosixShell(script) + ? setupShell + : { family: 'cmd' } : { family: 'posix' } + // Why: `shell` tells the launcher which shell types the command, not which format the runner + // file is in — the .cmd/.sh extension already carries that. Reporting the runner family here + // would make a Git Bash pane receive `cmd.exe /c ...`, whose `/c` MSYS rewrites into a drive + // path (issue #6896), so setup would open an interactive cmd and never run. const launchShell: SetupRunnerShell | undefined = nativeWindowsWorktree - ? runnerShell + ? (setupShell ?? { family: 'cmd' }) : process.platform === 'win32' && runtimeTarget?.wslDistro ? { family: 'posix', executable: 'wsl.exe' } : undefined @@ -583,8 +626,7 @@ function createWorktreeRunnerScript(args: { envVars, // Why: WSL git returns /mnt paths that Node converts back to C:\ for file // writes; retain the runtime signal so launch converts them to /mnt again. - // Issue-command runners take the same resolved shell, so one session never - // mixes a bash setup runner with a cmd issue runner. + // On native Windows it is the terminal's family, i.e. the shell that types the command. ...(launchShell ? { shell: launchShell } : {}), ...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {}) } @@ -615,9 +657,9 @@ export function resolveSetupRunnerShell( options.resolveGitBashShellPath ?? ((shell: string) => resolveWindowsGitBashShellPath(shell, { platform })) if (resolveGitBashShellPath(configuredShell)) { - // Note: Git Bash users with batch-syntax orca.yaml setup content get a bash - // interpreter from here on. The flip is intentional and documented in - // docs/reference/windows-setup-shell.md. + // Note: this reports the terminal's family — the shell that types the launch command, and + // therefore also that a bash runner *could* launch here. Whether one is written is decided + // per script by its `#!` line — see docs/reference/windows-setup-shell.md. return { family: 'posix' } } } diff --git a/src/main/runtime/orchestration/setup-completion-signal.test.ts b/src/main/runtime/orchestration/setup-completion-signal.test.ts index baa5af51cf3..73f29371b50 100644 --- a/src/main/runtime/orchestration/setup-completion-signal.test.ts +++ b/src/main/runtime/orchestration/setup-completion-signal.test.ts @@ -64,6 +64,19 @@ describe('orchestration setup completion signal', () => { expect(command).toContain('bash /c/repo/.git/orca/setup-runner.sh') }) + it('keeps a batch runner on the Windows completion path from a Git Bash pane', () => { + // Regression (#6896): a Git Bash terminal with a batch setup script still gets a .cmd + // runner; observing it must not shell out to bash or type a bare `cmd.exe /c` switch. + const runnerPath = 'C:\\repo\\.git\\orca\\setup-runner.cmd' + const observed = buildObservedSetupCommand(runnerPath, 'windows', 'token-git-bash-cmd', { + family: 'posix' + }) + + expect(observed.command).toContain('powershell.exe -NoLogo -NoProfile -NonInteractive') + expect(observed.command).not.toContain('bash ') + expect(observed.env).toEqual({ ORCA_SETUP_RUNNER_PATH: runnerPath }) + }) + it('recognizes one completion signal across output chunk boundaries', () => { const onComplete = vi.fn() const scanner = createSetupCompletionScanner('token-chunks', onComplete) diff --git a/src/shared/setup-agent-sequencing.test.ts b/src/shared/setup-agent-sequencing.test.ts index bffeb0de351..61dc001e627 100644 --- a/src/shared/setup-agent-sequencing.test.ts +++ b/src/shared/setup-agent-sequencing.test.ts @@ -218,6 +218,40 @@ describe('createSequencedSetupAgentCommands', () => { }) }) + it('launches a batch runner through the cmd launcher inside a Git Bash gate', () => { + // Regression (#6896): a Git Bash terminal with a batch setup script still gets a .cmd + // runner, and the gate must not hand that runner to bash. The gate itself stays POSIX + // because the Git Bash pane types it and quoted the startup command for bash. + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd', + startupCommand: "claude 'fix the user'\\''s login'", + platform: 'windows', + shell: { family: 'posix' }, + nonce: 'nonce-gitbash-cmd' + }) + + expect(result.setupCommand).toContain( + 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand' + ) + expect(result.setupCommand).not.toMatch(/bash\s+\S*setup-runner/) + expect(decodePowerShellScript(result.setupCommand)).toContain( + "$runner = 'C:\\repo\\.git\\orca\\setup-runner.cmd'" + ) + // Why: PowerShell's `Invoke-Expression` cannot parse the POSIX `'\''` escaping a Git Bash + // pane produces, so the gate that evaluates the startup command must be bash. + expect(result.setupCommand).toMatch(/^bash -lc /) + expect(result.startupCommand).toMatch(/^bash -lc /) + expect(result.startupCommand).not.toContain('Invoke-Expression') + expect(result.startupCommand).toContain('eval "$ORCA_SEQUENCED_STARTUP_COMMAND"') + // Why: bash writes and reads the marker here, so it needs the /c/... form of the path. + expect(result.setupCommand).toContain( + '/c/repo/.git/orca/setup-runner.cmd.nonce-gitbash-cmd.done' + ) + expect(result.startupCommand).toContain( + '/c/repo/.git/orca/setup-runner.cmd.nonce-gitbash-cmd.done' + ) + }) + it.skipIf(process.platform !== 'win32')( 'executes the native Windows setup-to-agent sequence through cmd.exe', async () => { diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index 92376f10886..1ec888e3aa6 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -1,5 +1,6 @@ import { encodePowerShellCommand } from './powershell-command-encoding' import { + nativeWindowsPathToPosixShellPath, resolveSetupRunnerCommand, type SetupRunnerCommandPlatform, type SetupRunnerCommandShell, @@ -41,12 +42,20 @@ export function createSequencedSetupAgentCommands(args: { }): SequencedSetupAgentCommands { const nonce = args.nonce ?? createSetupAgentSequenceNonce() const resolution = resolveSetupRunnerCommand(args.runnerScriptPath, args.platform, args.shell) + // Why: the gate is typed into the terminal pane and `startupCommand` is already quoted for that + // pane, so a batch runner launched from a Git Bash pane still needs the bash gate — PowerShell's + // `Invoke-Expression` cannot parse the POSIX `'\''` escaping the pane's quoting produces. The + // runner itself still launches through `resolution.command`, never through bash. + const posixGateForWindowsRunner = resolution.shell === 'windows' && args.shell?.family === 'posix' + const markerBasePath = posixGateForWindowsRunner + ? nativeWindowsPathToPosixShellPath(resolution.runnerScriptPathForShell) + : resolution.runnerScriptPathForShell // Why: overlapping gated launches of the same setup runner must not race on // a shared completion marker. - const markerPath = `${resolution.runnerScriptPathForShell}.${nonce}.done` + const markerPath = `${markerBasePath}.${nonce}.done` const waitTimeoutSeconds = args.waitTimeoutSeconds ?? DEFAULT_WAIT_TIMEOUT_SECONDS - if (resolution.shell === 'windows') { + if (resolution.shell === 'windows' && !posixGateForWindowsRunner) { return { setupCommand: buildWindowsSetupCommand( resolution.runnerScriptPathForShell, diff --git a/src/shared/setup-runner-command.test.ts b/src/shared/setup-runner-command.test.ts index 99ba2e029c4..069130b1313 100644 --- a/src/shared/setup-runner-command.test.ts +++ b/src/shared/setup-runner-command.test.ts @@ -61,6 +61,51 @@ describe('buildSetupRunnerCommand', () => { 'bash /c/repo/.git/orca/setup-runner.sh' ) }) + + it('never hands a batch runner to bash, even from a Git Bash pane', () => { + // Regression: a Git Bash terminal with a batch-syntax setup script gets a .cmd runner, + // so the launch shell being POSIX must not be read as "the runner is a shell script". + const command = buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', { + family: 'posix' + }) + + expect(command).not.toContain('bash ') + expect(command).not.toContain('/c/repo') + }) + + it('avoids the bare /c switch when a POSIX pane launches a batch runner', () => { + // Regression (#6896): MSYS rewrites `cmd.exe /c` into a drive path inside Git Bash, so cmd + // opens interactively and the runner payload never executes. + const command = buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', { + family: 'posix' + }) + + expect(command).not.toContain('cmd.exe /c') + expect(command).toMatch( + /^powershell\.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand [A-Za-z0-9+/=]+$/ + ) + }) + + it('keeps the batch runner path in native form for a POSIX pane launch', () => { + // Why: the PowerShell launcher hands the path to cmd, which cannot read /c/... MSYS paths; + // marker and completion paths derive from this value too. + expect( + resolveSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', { + family: 'posix' + }) + ).toMatchObject({ + runnerScriptPathForShell: 'C:\\repo\\.git\\orca\\setup-runner.cmd', + shell: 'windows' + }) + }) + + it('still uses bash for a POSIX runner launched from a POSIX pane', () => { + expect( + buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows', { + family: 'posix' + }) + ).toBe('bash /c/repo/.git/orca/setup-runner.sh') + }) }) describe('buildSetupRunnerCommand cmd metacharacter guard', () => { diff --git a/src/shared/setup-runner-command.ts b/src/shared/setup-runner-command.ts index fe9cebed50b..7b1dee560fd 100644 --- a/src/shared/setup-runner-command.ts +++ b/src/shared/setup-runner-command.ts @@ -60,7 +60,10 @@ export function resolveSetupRunnerCommand( shell: 'posix' } } - if (shell?.family === 'posix' || /\.sh$/i.test(runnerScriptPath)) { + // Why: `shell` is the shell that types the command; the runner file's own extension decides + // what can execute it. A batch runner never goes to bash even from a Git Bash pane. + const cmdRunnerFile = isWindowsCmdRunnerPath(runnerScriptPath) + if (!cmdRunnerFile && (shell?.family === 'posix' || /\.sh$/i.test(runnerScriptPath))) { // Why: WSL shells need /mnt/... paths, while Git Bash expects /c/... when replaying deferred setup scripts. if (isWslExecutable(shell?.executable)) { const wslPath = nativeWindowsPathToWslShellPath(runnerScriptPath) @@ -79,11 +82,15 @@ export function resolveSetupRunnerCommand( } } return { - // Why: some path characters survive no amount of quoting on a cmd command line, so those - // paths take a delayed-expansion launcher instead. Every other path keeps the plain form. - command: windowsRunnerPathNeedsCmdGuard(runnerScriptPath) - ? buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath) - : `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`, + // Why: some path characters survive no amount of quoting on a cmd command line, and a Git + // Bash pane rewrites the bare `/c` switch itself into a drive path (issue #6896) so cmd + // opens interactively and the runner never starts. Both take the delayed-expansion + // launcher, which passes the switch through a PowerShell ProcessStartInfo instead. Every + // other case keeps the plain form. + command: + shell?.family === 'posix' || windowsRunnerPathNeedsCmdGuard(runnerScriptPath) + ? buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath) + : `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`, runnerScriptPathForShell: runnerScriptPath, shell: 'windows' } @@ -96,6 +103,11 @@ export function resolveSetupRunnerCommand( } } +/** True when the runner file is a batch script, which only cmd can execute. */ +export function isWindowsCmdRunnerPath(runnerScriptPath: string): boolean { + return /\.(cmd|bat)$/i.test(runnerScriptPath) +} + export function isWslUncPath(path: string): boolean { const normalized = path.replace(/\\/g, '/') return /^\/\/(wsl\.localhost|wsl\$)\//i.test(normalized) diff --git a/src/shared/setup-script-shebang.test.ts b/src/shared/setup-script-shebang.test.ts new file mode 100644 index 00000000000..05dca1204da --- /dev/null +++ b/src/shared/setup-script-shebang.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { + isShebangLine, + parseSetupScriptShebang, + scriptDeclaresPosixShell, + stripLeadingShebangLine +} from './setup-script-shebang' + +describe('scriptDeclaresPosixShell', () => { + it('accepts the common env and absolute-path forms', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env bash\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!/bin/sh -e\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!/usr/bin/env -S bash -euo pipefail\npnpm install')).toBe( + true + ) + expect(scriptDeclaresPosixShell('#!/bin/zsh')).toBe(true) + }) + + it('rejects scripts with no interpreter line', () => { + // Regression: batch-syntax setup scripts must stay on the cmd runner. + expect(scriptDeclaresPosixShell('copy .env.example .env\nxcopy /E assets dist')).toBe(false) + expect(scriptDeclaresPosixShell('')).toBe(false) + expect(scriptDeclaresPosixShell('pnpm install\n#!/usr/bin/env bash')).toBe(false) + expect(scriptDeclaresPosixShell('# !/usr/bin/env bash\npnpm install')).toBe(false) + }) + + it('rejects interpreters that are not POSIX shells', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env node\nconsole.log(1)')).toBe(false) + expect(scriptDeclaresPosixShell('#!/usr/bin/env python3\nprint(1)')).toBe(false) + }) + + it('tolerates CRLF and Windows-style interpreter paths', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env bash\r\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!C:\\tools\\git\\bin\\bash.exe\r\npnpm install')).toBe(true) + }) +}) + +describe('parseSetupScriptShebang', () => { + it('keeps the interpreter flags a script declares', () => { + // Regression: the runner is launched as `bash `, so flags survive only if they are + // parsed out here and replayed with `set` — otherwise `pipefail` is silently lost. + expect(parseSetupScriptShebang('#!/usr/bin/env -S bash -euo pipefail\nmake')).toEqual({ + interpreter: 'bash', + shellOptions: ['-euo', 'pipefail'] + }) + expect(parseSetupScriptShebang('#!/bin/bash -e -x\nmake')).toEqual({ + interpreter: 'bash', + shellOptions: ['-e', '-x'] + }) + expect(parseSetupScriptShebang('#!/bin/sh\nmake')).toEqual({ + interpreter: 'sh', + shellOptions: [] + }) + }) + + it('ignores interpreter arguments that `set` cannot apply', () => { + // Regression: `set` rejects invocation-only flags with exit 2, and the runner's `set -e` + // turns that into an aborted setup before its first line runs. + expect(parseSetupScriptShebang('#!/bin/bash --norc\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -l\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -s\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -i\nmake')?.shellOptions).toEqual([]) + // Why: `-r` is accepted by `set` on some shells but silently restricts the rest of setup. + expect(parseSetupScriptShebang('#!/bin/bash -r\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -ex -l\nmake')?.shellOptions).toEqual(['-ex']) + // Why: a bare `-o` would print the whole shell-option table into the setup terminal. + expect(parseSetupScriptShebang('#!/bin/bash -o\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -euo\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash +o posix\nmake')?.shellOptions).toEqual([ + '+o', + 'posix' + ]) + }) + + it('returns null without an interpreter line', () => { + expect(parseSetupScriptShebang('pnpm install')).toBeNull() + expect(parseSetupScriptShebang('#!/usr/bin/env')).toBeNull() + }) +}) + +describe('stripLeadingShebangLine', () => { + it('removes only a leading interpreter line', () => { + expect(stripLeadingShebangLine('#!/usr/bin/env bash\npnpm install\n')).toBe('pnpm install\n') + expect(stripLeadingShebangLine('#!/usr/bin/env bash\r\npnpm install')).toBe('pnpm install') + expect(stripLeadingShebangLine('pnpm install\n#!/usr/bin/env bash')).toBe( + 'pnpm install\n#!/usr/bin/env bash' + ) + expect(stripLeadingShebangLine('#!/usr/bin/env bash')).toBe('') + }) +}) + +describe('isShebangLine', () => { + it('detects interpreter lines regardless of leading whitespace', () => { + expect(isShebangLine('#!/usr/bin/env bash')).toBe(true) + expect(isShebangLine(' #!/bin/sh')).toBe(true) + expect(isShebangLine('#comment')).toBe(false) + expect(isShebangLine('pnpm install')).toBe(false) + }) +}) diff --git a/src/shared/setup-script-shebang.ts b/src/shared/setup-script-shebang.ts new file mode 100644 index 00000000000..f30bbab0959 --- /dev/null +++ b/src/shared/setup-script-shebang.ts @@ -0,0 +1,100 @@ +// Why: the interpreter a setup/issue-command script is written for is a property of the script, +// not of the user's terminal preference, so a `#!` line is how a project declares it. + +const POSIX_SHELL_BASENAMES = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash']) +// Why: only the letters `set` itself accepts (`set [--abefhkmnptuvxBCHP] [-o option]`). An +// invocation-only flag such as `-l` or `-r` makes `set` exit 2, which under the runner's `set -e` +// aborts setup before its first line runs. +const SET_OPTION_FLAG_PATTERN = /^[-+][abefhkmnptuvxBCHP]+$/ +// Why: `-o`/`-euo` name their option in the next token; a trailing `-o` with no name would make +// `set` dump the whole shell-option table into the setup terminal instead. +const SET_LONG_OPTION_FLAG_PATTERN = /^[-+][abefhkmnptuvxBCHP]*o$/ +const SHELL_OPTION_NAME_PATTERN = /^[a-z_]+$/ + +export type SetupScriptShebang = { + /** Lowercased interpreter basename, e.g. `bash` for `#!/usr/bin/env -S bash -e`. */ + interpreter: string + /** Interpreter flags the generated runner replays through `set`, e.g. `['-euo', 'pipefail']`. */ + shellOptions: string[] +} + +/** True when `line` is a `#!` interpreter line (leading whitespace tolerated). */ +export function isShebangLine(line: string): boolean { + return line.trimStart().startsWith('#!') +} + +/** Parses the script's leading `#!` line, or null when it has none. */ +export function parseSetupScriptShebang(script: string): SetupScriptShebang | null { + const firstLine = script.split('\n', 1)[0] ?? '' + if (!isShebangLine(firstLine)) { + return null + } + + const tokens = firstLine.trim().slice(2).trim().split(/\s+/).filter(Boolean) + const interpreterIndex = findInterpreterIndex(tokens) + if (interpreterIndex === -1) { + return null + } + + return { + interpreter: executableBasename(tokens[interpreterIndex]), + shellOptions: parseShellOptions(tokens.slice(interpreterIndex + 1)) + } +} + +/** True when the script's first line is a `#!` line naming a POSIX shell. */ +export function scriptDeclaresPosixShell(script: string): boolean { + const shebang = parseSetupScriptShebang(script) + return shebang !== null && POSIX_SHELL_BASENAMES.has(shebang.interpreter) +} + +/** Drops a leading `#!` line; the generated runner carries its own interpreter line. */ +export function stripLeadingShebangLine(script: string): string { + if (!isShebangLine(script.split('\n', 1)[0] ?? '')) { + return script + } + const lineEnd = script.indexOf('\n') + return lineEnd === -1 ? '' : script.slice(lineEnd + 1) +} + +function findInterpreterIndex(tokens: string[]): number { + for (let index = 0; index < tokens.length; index++) { + const basename = executableBasename(tokens[index]) + // Why: `env` (and its `-S` split-string form) only forwards to the real interpreter. + if (basename === '' || basename === 'env' || basename.startsWith('-')) { + continue + } + return index + } + return -1 +} + +function parseShellOptions(tokens: string[]): string[] { + const options: string[] = [] + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index] + if (SET_LONG_OPTION_FLAG_PATTERN.test(token)) { + const optionName = tokens[index + 1] + if (optionName && SHELL_OPTION_NAME_PATTERN.test(optionName)) { + options.push(token, optionName) + index++ + } + continue + } + if (SET_OPTION_FLAG_PATTERN.test(token)) { + options.push(token) + } + } + return options +} + +function executableBasename(token: string): string { + return ( + token + .replaceAll('\\', '/') + .split('/') + .pop() + ?.toLowerCase() + .replace(/\.exe$/, '') ?? '' + ) +} From 401f66939d234200f3b770d3881115793b9c95f7 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:25:56 -0700 Subject: [PATCH 049/101] fix(codex): do not silently run the previous account when managed auth is unreadable (#12407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): never launch the previously synced account during the credential grace window The credential-absence grace window exists so a mid-rotation read of a managed auth.json does not deselect the account over a race. In the shared runtime-home lane it did that by returning early from syncForCurrentSelection, which leaves the runtime home holding whatever it held before — the previously synced account's credentials. So when the selected account's auth.json was missing or unreadable (home on a disconnected network drive, never provisioned, removed, or held under an EPERM/EACCES lock by Windows AV), the next launch ran Codex as the *previous* account while Settings showed the selected one: wrong quota, wrong identity, work attributed to the wrong account, and no signal to the user. Not being able to read the selected account's credentials proves nothing about the identity already mirrored in the runtime home. The grace window now only short-circuits when Orca can prove the runtime home already holds that same account (shared-runtime-auth provenance, falling back to the in-memory sync state on pre-provenance installs). Otherwise the runtime auth is cleared and the provenance fenced: the selection survives in case the absence heals, but the launch is logged out rather than logged in as someone else. Tests cover ENOENT, EACCES/EPERM, and a genuinely still-settling rotation of the selected account, which keeps its grace and its credentials. * fix(codex): keep a mirrored token refresh when refusing an unproven selection Clearing the shared runtime auth to avoid launching the wrong account can drop bytes Codex refreshed in place — on a cold start whose mirror still holds the previous account, no outgoing read-back has run yet, so those tokens exist nowhere else and OAuth refresh-token rotation would force a re-login. Read the mirror back to whichever managed account owns it (identity + freshness proven, as on an account switch) before removing it. * fix(codex): keep an unreadable managed home from dropping another account's mirrored refresh An account whose auth.json cannot be read aborted the whole read-back scan, so removing the runtime mirror for an unproven selection destroyed a token refresh it held for a different account. Skip the unreadable home instead. * fix(codex): prove mirror ownership from the credential, not just provenance Review found the unproven-selection clear was both too eager and too destructive: - A `fenced` provenance record (torn provenance file, or a crashed pending write) turned the grace window into a logout of the account the mirror actually and correctly held. - The pre-provenance fallback was tautological: initializeLastSyncedState seeds lastSyncedAccountId with the SELECTED account, so `missing && lastSyncedAccountId === id` is true on every cold start — the wrong-account launch still reproduced on installs with no provenance file. - The pre-delete read-back only rescued managed-owned bytes, so a system-default refresh living only in the mirror was destroyed with no path back to ~/.codex. - rmSync was unguarded on a branch whose entry condition is "the filesystem refused a read", so an EPERM/EBUSY lock could throw out of prepareForCodexLaunch, and the fence was written after the delete. - Swallowing managed-home read errors let an ambiguous candidate set collapse to a single match and be written to the wrong home. Ownership is now decided from the credential's own identity claims (codexAuthMatchesManagedAccount against the account record, no read of the unreadable home), then from the exact bytes Orca mirrored this run, and only then from provenance. The clear rescues a mirror-only system-default refresh to ~/.codex first (reusing the ownership rules syncRuntimeAuthWithSystemDefault already applies, now shared via resolveSystemDefaultMirrorClaim), fences before deleting, and never throws. An unreadable managed home whose record cannot rule it out now makes the read-back scan ambiguous instead of misattributing the bytes. Five tests added; each fails on the previous commit and passes here. * fix(codex): never let the fence cancel the delete, or unattributable bytes cause a logout Round-2 review found the previous response commit walked the guard backwards in three places: - `fence before deleting` put persistSharedRuntimeAuthProvenance and rmSync in one try, so a fence write the OS refuses (Windows AV lock on the provenance file) cancelled the delete and the launch ran with the other account's credentials — the original defect, reproduced. The delete now runs from a finally: the fence is still attempted first, but neither it nor the read-back rescue can skip it. - Credentials with no OAuth identity claims (apikey/agentIdentity/PAT/bedrock, all first-class in managed-codex-auth-readiness) can never satisfy a positive ownership proof, so a pre-provenance install deleted the mirror it correctly held during a transient absence. With no committed provenance record left, ownership is now refuted rather than proven: only claims that contradict the account rule the mirror out, and claims a credential does not carry contradict nothing. - A stale record email hard-failed the proof even when the account id matched exactly. Orca only refreshes account.email on add/re-auth, so a renamed ChatGPT account kept a stale email indefinitely and both lost its mirror and had its refreshes dropped instead of read back. A positive account-id match now outranks the email, in one shared rule both identity predicates use. Four tests added; each fails without its fix. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../codex-accounts/codex-auth-identity.ts | 63 +- .../codex-credential-absence-grace.test.ts | 17 +- .../managed-codex-auth-readiness.test.ts | 14 +- .../runtime-home-service.test.ts | 572 +++++++++++++++++- .../codex-accounts/runtime-home-service.ts | 167 ++++- 5 files changed, 809 insertions(+), 24 deletions(-) diff --git a/src/main/codex-accounts/codex-auth-identity.ts b/src/main/codex-accounts/codex-auth-identity.ts index e8d094f03b6..5c62dbfe272 100644 --- a/src/main/codex-accounts/codex-auth-identity.ts +++ b/src/main/codex-accounts/codex-auth-identity.ts @@ -29,8 +29,17 @@ export function codexAuthMatchesManagedAccount( normalizeField(account.workspaceAccountId), managedIdentity?.workspaceAccountId ) - const emailMatches = Boolean(selectedEmail && identity.email && selectedEmail === identity.email) - if (selectedEmail && identity.email && selectedEmail !== identity.email) { + const emailMatches = identityFieldsAgree(selectedEmail, identity.email) + if ( + identityContradictsSelection( + { + email: selectedEmail, + providerAccountId: selectedProviderId, + workspaceAccountId: selectedWorkspaceId + }, + identity + ) + ) { return false } if (!identityFieldMatches(selectedProviderId, identity.providerAccountId)) { @@ -50,6 +59,26 @@ export function codexAuthMatchesManagedAccount( ) } +// Why: an unreadable managed home cannot be compared byte-for-byte, so only the +// account record itself can rule that home out as the credential's owner. +export function codexAuthCouldBelongToManagedAccount( + runtimeAuthContents: string, + account: CodexManagedAccount +): boolean { + const identity = readIdentityFromAuthContents(runtimeAuthContents) + return ( + !identity || + !identityContradictsSelection( + { + email: normalizeField(account.email), + providerAccountId: normalizeField(account.providerAccountId), + workspaceAccountId: normalizeField(account.workspaceAccountId) + }, + identity + ) + ) +} + // Why: the shared mirror may still hold managed credentials; only the same // positively identified system account may ever be read back to ~/.codex. export function codexAuthMatchesSystemDefaultIdentity( @@ -240,3 +269,33 @@ function firstNonNull(...values: (string | null | undefined)[]): string | null { function identityFieldMatches(selectedField: string | null, runtimeField: string | null): boolean { return !selectedField || Boolean(runtimeField && selectedField === runtimeField) } + +// Why: only a claim both sides carry can rule an account out — a credential with +// no identity claims (api key, PAT, bedrock) contradicts nothing. A ChatGPT +// rename leaves the record email stale, so a matching account id outranks it. +function identityContradictsSelection( + selected: CodexAuthIdentity, + identity: CodexAuthIdentity +): boolean { + if ( + identityFieldsConflict(selected.providerAccountId, identity.providerAccountId) || + identityFieldsConflict(selected.workspaceAccountId, identity.workspaceAccountId) + ) { + return true + } + return ( + identityFieldsConflict(selected.email, identity.email) && + !identityFieldsAgree(selected.providerAccountId, identity.providerAccountId) + ) +} + +function identityFieldsAgree(selectedField: string | null, runtimeField: string | null): boolean { + return Boolean(selectedField && runtimeField && selectedField === runtimeField) +} + +function identityFieldsConflict( + selectedField: string | null, + runtimeField: string | null +): boolean { + return Boolean(selectedField && runtimeField && selectedField !== runtimeField) +} diff --git a/src/main/codex-accounts/codex-credential-absence-grace.test.ts b/src/main/codex-accounts/codex-credential-absence-grace.test.ts index 6672bb7676a..37e360a3a6e 100644 --- a/src/main/codex-accounts/codex-credential-absence-grace.test.ts +++ b/src/main/codex-accounts/codex-credential-absence-grace.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -57,6 +57,21 @@ describe('CodexCredentialAbsenceGrace', () => { }) }) + it('keeps a permission-denied read inside the grace window before it turns durable', () => { + if (process.platform === 'win32' || process.getuid?.() === 0) { + return + } + const grace = new CodexCredentialAbsenceGrace() + writeFileSync(authPath, VALID_AUTH, 'utf-8') + chmodSync(authPath, 0o000) + + expect(grace.assess(authPath, 1_000)).toEqual({ state: 'unreadable', durable: false }) + expect(grace.assess(authPath, 1_000 + CODEX_CREDENTIAL_ABSENCE_GRACE_MS)).toEqual({ + state: 'unreadable', + durable: true + }) + }) + it('reports settled credential-free JSON as durable immediately', () => { const grace = new CodexCredentialAbsenceGrace() writeFileSync(authPath, '{}', 'utf-8') diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts index 4de1da70396..abd6a4881ee 100644 --- a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts +++ b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -208,6 +208,18 @@ describe('waitForManagedCodexAuthReady', () => { ) }) + it('separates an absent credential from one that cannot be read', () => { + const fixture = createFixture() + expect(readStoredCodexCredentialState(join(fixture.home, 'auth.json'))).toBe('missing') + + if (process.platform === 'win32' || process.getuid?.() === 0) { + return + } + writeAuth(fixture.home, testChatGptAuth) + chmodSync(join(fixture.home, 'auth.json'), 0o000) + expect(readStoredCodexCredentialState(join(fixture.home, 'auth.json'))).toBe('unreadable') + }) + it('does not gate system, WSL, or unmanaged custom homes', async () => { const fixture = createFixture() await waitForManagedCodexAuthReady({ diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index d4ab194705d..3449dbd28f4 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -16,7 +16,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { GlobalSettings } from '../../shared/types' +import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' import type * as ShellStartupEnv from '../pty/shell-startup-env' const testState = { @@ -259,6 +259,25 @@ function createManagedAuth(rootDir: string, accountId: string, auth: string): st return managedHomePath } +function createCodexAccountRecord( + id: string, + email: string, + providerAccountId: string, + managedHomePath: string +): CodexManagedAccount { + return { + id, + email, + managedHomePath, + providerAccountId, + workspaceLabel: null, + workspaceAccountId: providerAccountId, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + function encodeJwtPart(value: Record): string { return Buffer.from(JSON.stringify(value)).toString('base64url') } @@ -467,6 +486,557 @@ describe('CodexRuntimeHomeService', () => { 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('never launches another account on a cold start with no provenance record', 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') + ) + // Pre-provenance install: the mirror still holds account A, settings already + // select account B, and nothing on disk records who wrote those bytes. + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + 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) ? 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('survives a runtime auth delete the filesystem refuses', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const homeA = createManagedAuth( + testState.userDataDir, + 'account-a', + createCodexAuthJson('a@example.com', 'acct-a', 'a') + ) + const homeB = createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'b') + ) + // Stands in for a Windows AV/EBUSY lock: the mirror auth.json cannot be + // removed, and the branch is only reached because reads are already failing. + mkdirSync(runtimeAuthPath, { recursive: true }) + 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', '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()).not.toThrow() + // The bytes survived the failed delete, so they must at least be fenced off. + expect(JSON.parse(readFileSync(getSharedRuntimeAuthProvenancePath(), 'utf-8'))).toEqual({ + owner: 'fenced' + }) + rmSync(runtimeAuthPath, { recursive: true, force: true }) + }) + + 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 + } + const runtimeAuthPath = getRuntimeCodexAuthPath() + writeFileSync( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const authX = createCodexAuthJson('x@example.com', 'acct-x', 'x', 1) + const authXRefreshed = createCodexAuthJson('x@example.com', 'acct-x', 'x-refreshed', 2) + // Two records for the same identity: one home unreadable, one readable. + const homeX1 = createManagedAuth(testState.userDataDir, 'account-x1', authX) + const homeX2 = createManagedAuth(testState.userDataDir, 'account-x2', authX) + const homeB = createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'b') + ) + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(runtimeAuthPath, authXRefreshed, 'utf-8') + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ owner: 'managed', accountId: 'account-x1' })}\n`, + 'utf-8' + ) + chmodSync(join(homeX1, 'auth.json'), 0o000) + rmSync(join(homeB, 'auth.json'), { force: true }) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-x1', 'x@example.com', 'acct-x', homeX1), + createCodexAccountRecord('account-x2', 'x@example.com', 'acct-x', homeX2), + 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(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 a mirrored api-key credential no identity claim can attribute', 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) + // Pre-provenance install, cold start: the mirror holds the selected account's + // own api-key credential — bytes carrying no id_token to identify — while its + // auth.json reads torn mid-rotation. + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(runtimeAuthPath, apiKeyAuth, 'utf-8') + rmSync(getSharedRuntimeAuthProvenancePath(), { force: true }) + writeFileSync(join(managedHomePath, 'auth.json'), '{"OPENAI_API_K', '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(apiKeyAuth) + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') + }) + + 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( + getSystemCodexAuthPath(), + createCodexAuthJson('system@example.com', 'acct-system', 'system'), + 'utf-8' + ) + const renamedAuth = createCodexAuthJson('new@example.com', 'acct-user', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', renamedAuth) + // Torn provenance can no longer vouch for the mirror, and the only remaining + // evidence is a credential whose email no longer matches the record. + mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writeFileSync(runtimeAuthPath, renamedAuth, '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', 'old@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(renamedAuth) + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') + }) + it('repoints legacy active host CODEX_HOME to the shared runtime home on startup', async () => { const legacyLaunchHomePath = join( testState.userDataDir, diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index fac946c51b2..7ab8f8a9766 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -72,6 +72,7 @@ import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-pa import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' import { + codexAuthCouldBelongToManagedAccount, codexAuthIsFresher, codexAuthMatchesManagedAccount, codexAuthMatchesSystemDefaultIdentity @@ -741,11 +742,21 @@ export class CodexRuntimeHomeService { const authAbsence = this.credentialAbsenceGrace.assess(activeAuthPath) if (authAbsence.state !== 'present' && authAbsence.state !== 'incomplete') { if (!authAbsence.durable) { - // Why: mid-rotation reads look missing/unreadable for a moment; skip - // this sync without deselecting and let a settled read decide later. + 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, keeping selection through grace window' + '[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth' ) + this.clearRuntimeAuthForUnprovenSelection() return } console.warn( @@ -920,6 +931,116 @@ export class CodexRuntimeHomeService { } } + // Why: an unreadable managed auth.json says nothing about who the shared + // runtime home currently holds. The credential's own identity claims answer + // that without touching the unreadable home; Orca's write records and its + // provenance file are corroboration, not the only evidence — a torn or fenced + // provenance file is no proof the bytes belong to somebody else. + 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 + } + } + const status = this.resolveSharedRuntimeAuthProvenanceStatus() + if (status.kind === 'committed') { + return status.provenance.owner === 'managed' && status.provenance.accountId === account.id + } + // Why: with no committed record left, only claims that contradict this + // account prove the mirror is somebody else's. Deleting bytes nothing can + // attribute logs the user out over the very absence the grace window covers. + return runtimeAuth !== null && codexAuthCouldBelongToManagedAccount(runtimeAuth, account) + } + + // 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 { + 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 a delete the OS refuses (Windows AV lock, EBUSY) + // still leaves the surviving bytes marked untrusted. + const readBackResult = this.readBackRefreshedTokensFromPath(runtimeAuthPath, { + updateLastWrittenAuthJson: false + }) + if (readBackResult === 'rejected') { + this.readBackRefreshedSystemDefaultAuth() + } + this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) + } finally { + // Why: neither the rescue nor the fence may cancel the delete — leaving + // another identity's credentials for the launch is the defect itself. + this.lastWrittenAuthJson = null + rmSync(runtimeAuthPath, { force: true }) + } + } catch (error) { + // Why: this branch only runs because the filesystem already refused a + // read; a second failure must not throw out of launch preparation. + console.warn('[codex-runtime-home] Failed to clear unproven runtime auth:', 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( + runtimeAuth: string, + provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus + ): { ownershipProven: boolean; mirroredAuthJson: string | null } { + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const snapshotAuth = + this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null + const preProvenanceRuntimeRefreshProven = + provenanceStatus.kind === 'missing' && + snapshotAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && + codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) + return { + ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven, + mirroredAuthJson: + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshotAuth) + : null + } + } + private safeSyncForCurrentSelection(): void { try { this.syncForCurrentSelection() @@ -1234,6 +1355,7 @@ export class CodexRuntimeHomeService { managedAuthPath: string managedAuthContents: string }[] = [] + let unreadableHomeCouldOwnRuntimeAuth = false for (const account of this.store.getSettings().codexManagedAccounts) { if (expectedAccountId && account.id !== expectedAccountId) { continue @@ -1242,12 +1364,30 @@ export class CodexRuntimeHomeService { if (!existsSync(managedAuthPath)) { continue } - const managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + let managedAuthContents: string + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { + // Why: an unreadable home can never be compared, but letting the read + // throw abandons the scan for every other account — dropping a refresh + // the runtime home holds for one of them. Only its record can rule it + // out as the owner; when it cannot, the scan is no longer unambiguous. + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { matches.push({ account, managedAuthPath, managedAuthContents }) } } + if (unreadableHomeCouldOwnRuntimeAuth) { + return { kind: 'ambiguous' } + } if (matches.length === 1) { return { kind: 'matched', ...matches[0] } } @@ -1652,21 +1792,10 @@ export class CodexRuntimeHomeService { }) return } - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const snapshotAuth = snapshot?.authJson ?? null - const preProvenanceRuntimeRefreshProven = - provenanceStatus.kind === 'missing' && - snapshotAuth !== null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && - codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) - const systemDefaultOwnershipProven = - provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven - const mirroredSystemDefaultAuth = - provenance?.owner === 'system-default' - ? provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshotAuth) - : null + const { + ownershipProven: systemDefaultOwnershipProven, + mirroredAuthJson: mirroredSystemDefaultAuth + } = this.resolveSystemDefaultMirrorClaim(runtimeAuth, provenanceStatus) if (!existsSync(systemDefaultAuthPath)) { if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) From fb1259a09d3e352899f8287ca73865b8d0b3d361 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:26:02 -0700 Subject: [PATCH 050/101] fix(mobile): keep cached workspace counts across a transient RPC failure (#12408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): keep cached workspace counts across a transient RPC failure The Home host card showed "12 worktrees · 2 active" until any worktree.ps failed — a backgrounded app, a Wi-Fi→cellular handoff, or a sleep/resume that kills the socket mid-request. Two things then went wrong: - render dropped the counts: `markHomeWorktreeCatalogUnavailable` kept the proven numbers in state, but the card only rendered them when `catalogUnavailable` was unset, so the line collapsed to "Worktree list unavailable" even though the last successful counts were right there. - nothing re-drove the fetch: the per-host wiring latched a `statsFetched` boolean on the first connect, and the logical client survives socket drops, so its reconnect never re-read the catalog. The card stayed wrong until the user navigated away and back. Keep the proven counts and flag them stale (`staleCounts`), rendered as "Last known: 12 worktrees · 2 active"; a host whose catalog never loaded still reads "Worktree list unavailable" (STA-3123). Replace the one-shot latch with createHostConnectRefetchGate, which fires on each transition INTO 'connected' — one refetch per reconnect, no polling timer — mirroring useWorktreeResync on the host screen. fetchHomeHostWorktreeInfo moves out of app/index.tsx so its rejection path is covered by tests. * fix(mobile): bound "Last known" counts and survive a path cutover Review found two ways the home host card's stale-count fix misbehaves. 1. A migrateTo cutover (relay->direct probe, forced replacement) rejects in-flight requests with LogicalClientCutoverError and republishes 'connected' from 'connected', so the connect gate never re-arms and the card latched on "Last known: ..." with nothing left to clear it. worktree.ps now re-issues on the authenticated replacement, bounded, like runtime-capability-probe and worktree-create-retry already do. 2. "Last known: N worktrees" had no age bound. The home snapshot is persisted, so a cold start whose first worktree.ps failed rendered counts proven days ago exactly like counts proven seconds ago - the case STA-3123 deliberately rendered as "Worktree list unavailable". Counts now carry countsProvenAt and expire out of the "last known" wording after 10 minutes; counts persisted by an older build count as expired. Also, per review: the card derives its own worktree line from HostWorktreeInfo, so a caller can no longer re-gate the counts away (that was the original defect), and the derivation is covered by a render test - mobile/vitest.config.ts never collected *.test.tsx, so component tests were silently dead. Home stats are keyed by host and summed instead of letting whichever desktop replied last overwrite the shared header row, which the per-reconnect refetch made churn on flaky links. * fix(mobile): age bounds liveness, not the counts; scope the header total to paired hosts Round-2 review follow-up. Age bound was anchored on proof time inside the failure branch only, so a session connected past the window that then hit one failed refresh rendered the pre-fix "Worktree list unavailable" — the exact case this PR exists for — while identically aged counts still rendered unlabeled as live whenever the refresh was merely pending. Age now decides live vs "Last known" and the failure branch keeps whatever the host last proved; "Worktree list unavailable" is reserved for a catalog that never loaded. Header stats summed every entry ever cached, so removing a desktop left its lifetime numbers in the total for the rest of the session. totalHomeStats now sums the hosts still paired, which also covers removal from the host screen. wireHostSubscriptions is the effect body moved verbatim out of useEffect; react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one and the changed-code gate has no working suppression path (an inline directive reads as unused to the plugin-less scan). --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- mobile/app/index.tsx | 200 ++++++--------- mobile/src/cache/home-snapshot-cache.ts | 19 +- mobile/src/components/MobileHostCard.test.tsx | 93 +++++++ mobile/src/components/MobileHostCard.tsx | 14 +- mobile/src/stats/home-stats-total.test.ts | 81 ++++++ mobile/src/stats/home-stats-total.ts | 51 ++++ .../host-connect-refetch-gate.test.ts | 28 +++ .../transport/host-connect-refetch-gate.ts | 18 ++ .../worktree/home-host-worktree-fetch.test.ts | 236 ++++++++++++++++++ .../src/worktree/home-host-worktree-fetch.ts | 79 ++++++ .../src/worktree/home-worktree-info.test.ts | 80 +++++- mobile/src/worktree/home-worktree-info.ts | 42 +++- mobile/vitest.config.ts | 4 +- 13 files changed, 791 insertions(+), 154 deletions(-) create mode 100644 mobile/src/components/MobileHostCard.test.tsx create mode 100644 mobile/src/stats/home-stats-total.test.ts create mode 100644 mobile/src/stats/home-stats-total.ts create mode 100644 mobile/src/transport/host-connect-refetch-gate.test.ts create mode 100644 mobile/src/transport/host-connect-refetch-gate.ts create mode 100644 mobile/src/worktree/home-host-worktree-fetch.test.ts create mode 100644 mobile/src/worktree/home-host-worktree-fetch.ts diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 907f50ed974..57532a4a8ae 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -18,14 +18,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { loadHosts } from '../src/transport/host-store' import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' -import { pickResumeWorktree } from '../src/worktree/resume-worktree' -import { WORKTREE_PS_FULL_LIMIT } from '../src/worktree/worktree-catalog-snapshot-client' -import { - markHomeWorktreeCatalogUnavailable, - type HomeWorktreeSummary, - type HostWorktreeInfo -} from '../src/worktree/home-worktree-info' +import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch' +import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total' +import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info' import type { RpcClient } from '../src/transport/rpc-client' +import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate' import { sendSingleFlightRequest } from '../src/transport/request-single-flight' import { useAllHostClients, @@ -68,13 +65,6 @@ function endpointLabel(endpoint: string): string { } } -type StatsSummary = { - totalAgentsSpawned: number - totalPRsCreated: number - totalAgentTimeMs: number - firstEventAt: number | null -} - type HomeTaskSettings = { visibleTaskProviders?: unknown } @@ -123,7 +113,9 @@ function clientKey(client: RpcClient): number { function fetchStats( client: RpcClient, hostId: string, - setStats: (s: StatsSummary) => void, + setStats: ( + updater: (prev: Record) => Record + ) => void, disposed: () => boolean ) { sendSingleFlightRequest(client, hostId, 'stats.summary') @@ -132,61 +124,13 @@ function fetchStats( return } if (response.ok) { - setStats(response.result as StatsSummary) + // Keyed by host: the header totals every desktop instead of showing whoever replied last. + setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary })) } }) .catch(() => {}) } -function fetchWorktreeInfo( - client: RpcClient, - hostId: string, - setInfo: ( - updater: (prev: Record) => Record - ) => void, - disposed: () => boolean -) { - const markUnavailable = () => { - setInfo((prev) => { - const current = prev[hostId] - const next = markHomeWorktreeCatalogUnavailable(current, hostId) - return next === current ? prev : { ...prev, [hostId]: next } - }) - } - - sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT }) - .then((response) => { - if (disposed()) { - return - } - if (response.ok) { - const result = response.result as { worktrees: HomeWorktreeSummary[] } - const worktrees = result.worktrees ?? [] - setCachedWorktrees(hostId, worktrees) - const activeStatuses = new Set(['working', 'active', 'permission']) - const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status)) - // Mirror the desktop's focused workspace (see pickResumeWorktree). - const lastActive = pickResumeWorktree(worktrees) - setInfo((prev) => ({ - ...prev, - [hostId]: { - hostId, - totalWorktrees: worktrees.length, - activeCount: active.length, - lastActiveWorktree: lastActive - } - })) - } else { - markUnavailable() - } - }) - .catch(() => { - if (!disposed()) { - markUnavailable() - } - }) -} - function fetchAccountsSnapshot( client: RpcClient, hostId: string, @@ -272,7 +216,7 @@ export default function HomeScreen() { const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) const [hostLastConnected, setHostLastConnected] = useState>({}) - const [stats, setStats] = useState(null) + const [statsByHost, setStatsByHost] = useState>({}) const [worktreeInfo, setWorktreeInfo] = useState>({}) const [accountsByHost, setAccountsByHost] = useState>({}) const [taskProvidersByHost, setTaskProvidersByHost] = useState>({}) @@ -285,6 +229,8 @@ export default function HomeScreen() { // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. 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 hostPaths = useMemo( () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), @@ -375,8 +321,8 @@ export default function HomeScreen() { }) for (const entry of allClientsRef.current) { if (entry.client.getState() === 'connected') { - fetchStats(entry.client, entry.hostId, setStats, () => stale) - fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) + fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale) + void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale) fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale) } @@ -457,60 +403,70 @@ export default function HomeScreen() { }) }, [allClients, hosts]) - // Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap. - useEffect(() => { - const cleanups: Array<() => void> = [] - for (const entry of allClients) { - let unsubNotif: (() => void) | null = null - let unsubAccounts: (() => void) | null = null - let statsFetched = false - const wireUp = (state: ConnectionState) => { - if (state === 'connected') { - if (!unsubNotif) { - unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) - } - if (!unsubAccounts) { - unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { - if (!payload || typeof payload !== 'object') { - return + // 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 + // returned disposer owns every handle allocated here. + const wireHostSubscriptions = (entry: { + hostId: string + client: RpcClient + state: ConnectionState + }) => { + let unsubNotif: (() => void) | null = null + let unsubAccounts: (() => void) | null = null + const refetchGate = createHostConnectRefetchGate() + const wireUp = (state: ConnectionState) => { + const reconnected = refetchGate.observe(state) + if (state === 'connected') { + if (!unsubNotif) { + unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) + } + if (!unsubAccounts) { + unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { + if (!payload || typeof payload !== 'object') { + return + } + const evt = payload as { type?: string; snapshot?: unknown } + if (evt.type === 'ready' || evt.type === 'snapshot') { + try { + const snapshot = decodeAccountsSnapshot(evt.snapshot) + setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) + } catch { + // Keep the last proven snapshot; malformed remote data must + // not enter render state or crash the home host cards. } - const evt = payload as { type?: string; snapshot?: unknown } - if (evt.type === 'ready' || evt.type === 'snapshot') { - try { - const snapshot = decodeAccountsSnapshot(evt.snapshot) - setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) - } catch { - // Keep the last proven snapshot; malformed remote data must - // not enter render state or crash the home host cards. - } - } - }) - } - if (!statsFetched) { - statsFetched = true - fetchStats(entry.client, entry.hostId, setStats, () => false) - fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) - fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) - } - } else { - if (unsubNotif) { - unsubNotif() - unsubNotif = null - } - if (unsubAccounts) { - unsubAccounts() - unsubAccounts = null - } + } + }) + } + // Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host + // snapshot on every reconnect — a one-shot latch left the card on stale data forever. + if (reconnected) { + fetchStats(entry.client, entry.hostId, setStatsByHost, () => false) + void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) + fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) + } + } else { + if (unsubNotif) { + unsubNotif() + unsubNotif = null + } + if (unsubAccounts) { + unsubAccounts() + unsubAccounts = null } } - wireUp(entry.state) - const unsubState = entry.client.onStateChange(wireUp) - cleanups.push(() => { - unsubState() - unsubNotif?.() - unsubAccounts?.() - }) } + wireUp(entry.state) + const unsubState = entry.client.onStateChange(wireUp) + return () => { + unsubState() + unsubNotif?.() + unsubAccounts?.() + } + } + + // Re-runs per (hostId, client) pair; the socket stays open so it's cheap. + useEffect(() => { + const cleanups = allClients.map((entry) => wireHostSubscriptions(entry)) return () => { for (const c of cleanups) { c() @@ -750,7 +706,6 @@ export default function HomeScreen() { const state = hostStates[item.id] ?? 'connecting' const attempts = hostAttempts[item.id] ?? 0 const lastConnectedAt = hostLastConnected[item.id] ?? null - const info = worktreeInfo[item.id] const verdict = classifyConnection({ state, reconnectAttempts: attempts, @@ -763,12 +718,7 @@ export default function HomeScreen() { state={state} verdict={verdict} path={hostPaths[item.id] ?? 'lan'} - worktreeCounts={ - info && !info.catalogUnavailable - ? { total: info.totalWorktrees, active: info.activeCount } - : undefined - } - worktreeCountsUnavailable={info?.catalogUnavailable === true} + worktreeInfo={worktreeInfo[item.id]} onPress={() => router.push(`/h/${item.id}`)} onLongPress={() => { triggerMediumImpact() diff --git a/mobile/src/cache/home-snapshot-cache.ts b/mobile/src/cache/home-snapshot-cache.ts index a591bb01d70..bbda033735e 100644 --- a/mobile/src/cache/home-snapshot-cache.ts +++ b/mobile/src/cache/home-snapshot-cache.ts @@ -5,25 +5,12 @@ // WebSocket reconnects and the first responses come back. import AsyncStorage from '@react-native-async-storage/async-storage' import type { AccountsSnapshot } from '../components/AccountUsage' +// Why: the canonical shape, so persisted counts keep carrying countsProvenAt — the home card +// needs it to know whether a rehydrated count is minutes or days old. +import type { HostWorktreeInfo } from '../worktree/home-worktree-info' const STORAGE_KEY = 'orca:home-snapshot:v1' -type WorktreeSummary = { - worktreeId: string - repo: string - branch: string - displayName: string - liveTerminalCount: number - status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' -} - -type HostWorktreeInfo = { - hostId: string - totalWorktrees: number - activeCount: number - lastActiveWorktree: WorktreeSummary | null -} - export type HomeSnapshot = { worktreeInfo: Record accountsByHost: Record diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx new file mode 100644 index 00000000000..a1889793982 --- /dev/null +++ b/mobile/src/components/MobileHostCard.test.tsx @@ -0,0 +1,93 @@ +import { createElement } from 'react' +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 { HostProfile } from '../transport/types' +import { + markHomeWorktreeCatalogUnavailable, + type HostWorktreeInfo +} from '../worktree/home-worktree-info' +import { MobileHostCard } from './MobileHostCard' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight', Monitor: 'Monitor' })) +vi.mock('./StatusDot', () => ({ StatusDot: 'StatusDot' })) + +const host: HostProfile = { + id: 'host-1', + name: 'Studio', + endpoint: 'ws://studio.local:8765', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 0 +} +const verdict: ConnectionVerdict = { kind: 'normal', label: 'Connected' } +const loaded: HostWorktreeInfo = { + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: null, + countsProvenAt: Date.now() +} + +describe('MobileHostCard', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function renderCard(worktreeInfo: HostWorktreeInfo | undefined): Promise { + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host, + state: 'connected', + verdict, + path: 'lan', + worktreeInfo, + onPress: () => {}, + onLongPress: () => {} + }) + ) + }) + return renderer!.root + .findAllByType('Text') + .flatMap((node) => node.children.filter((child) => typeof child === 'string')) + } + + it('renders the counts the host proved', async () => { + expect(await renderCard(loaded)).toContain('12 worktrees · 2 active') + }) + + it('keeps rendering the last proven counts after a failed refresh', async () => { + // The regression this card shipped once: the caller dropped the counts the + // failure path deliberately preserved. + expect(await renderCard(markHomeWorktreeCatalogUnavailable(loaded, 'host-1'))).toContain( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('never asserts a count for a catalog that failed with nothing proven', async () => { + expect(await renderCard(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toContain( + 'Worktree list unavailable' + ) + }) + + it('shows no worktree line before the first read lands', async () => { + const lines = await renderCard(undefined) + + expect(lines).not.toContain('0 worktrees') + expect(lines).not.toContain('Worktree list unavailable') + }) +}) diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index 4e466922f4a..79a58bf760e 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -6,6 +6,7 @@ import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-l import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' import type { ConnectionState, 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: { @@ -13,20 +14,15 @@ export function MobileHostCard(props: { state: ConnectionState verdict: ConnectionVerdict path: MobileConnectionPath - worktreeCounts?: { total: number; active: number } - // Why (STA-3123): the host is connected but its worktree catalog request failed, - // so the card must say "unavailable" instead of asserting a count of zero. - worktreeCountsUnavailable?: boolean + // Why: the card owns the fresh/stale/unavailable wording so no caller can re-gate the counts + // away (STA-3123 shipped that bug once already). + worktreeInfo?: HostWorktreeInfo onPress: () => void onLongPress: () => void }) { const connected = props.state === 'connected' const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) - const worktreeSummary = props.worktreeCounts - ? `${props.worktreeCounts.total} worktree${props.worktreeCounts.total === 1 ? '' : 's'}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}` - : props.worktreeCountsUnavailable - ? 'Worktree list unavailable' - : null + const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo) return ( [styles.card, pressed && styles.cardPressed]} diff --git a/mobile/src/stats/home-stats-total.test.ts b/mobile/src/stats/home-stats-total.test.ts new file mode 100644 index 00000000000..035f6c7476f --- /dev/null +++ b/mobile/src/stats/home-stats-total.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { totalHomeStats, type HomeStatsSummary } from './home-stats-total' + +function stats(overrides: Partial = {}): HomeStatsSummary { + return { + totalAgentsSpawned: 10, + totalPRsCreated: 2, + totalAgentTimeMs: 60_000, + firstEventAt: 1_700_000_000_000, + ...overrides + } +} + +describe('totalHomeStats', () => { + it('has nothing to show before any host answers', () => { + expect(totalHomeStats({}, ['host-1'])).toBeNull() + }) + + it('passes a single host through unchanged', () => { + expect(totalHomeStats({ 'host-1': stats() }, ['host-1'])).toEqual(stats()) + }) + + it('sums every desktop instead of letting the last reply win', () => { + const total = totalHomeStats( + { + 'host-1': stats(), + 'host-2': stats({ totalAgentsSpawned: 5, totalPRsCreated: 1, totalAgentTimeMs: 30_000 }) + }, + ['host-1', 'host-2'] + ) + + expect(total).toMatchObject({ + totalAgentsSpawned: 15, + totalPRsCreated: 3, + totalAgentTimeMs: 90_000 + }) + }) + + it('drops a removed desktop from the total', () => { + // Replies are cached for the life of the process, so the entry outlives the pairing. + const byHost = { + 'host-1': stats(), + 'host-2': stats({ totalAgentsSpawned: 5, totalPRsCreated: 1, totalAgentTimeMs: 30_000 }) + } + + expect(totalHomeStats(byHost, ['host-1'])).toEqual(stats()) + expect(totalHomeStats(byHost, [])).toBeNull() + }) + + it('keeps the earliest known first event', () => { + const total = totalHomeStats( + { + 'host-1': stats({ firstEventAt: 2_000 }), + 'host-2': stats({ firstEventAt: null }), + 'host-3': stats({ firstEventAt: 1_000 }) + }, + ['host-1', 'host-2', 'host-3'] + ) + + expect(total?.firstEventAt).toBe(1_000) + }) + + it('reports no first event when no host has one', () => { + expect( + totalHomeStats({ 'host-1': stats({ firstEventAt: null }) }, ['host-1'])?.firstEventAt + ).toBeNull() + }) + + it('ignores a malformed reply instead of poisoning the header', () => { + const total = totalHomeStats( + { + 'host-1': stats(), + 'host-2': null as unknown as HomeStatsSummary, + 'host-3': { totalAgentsSpawned: 'lots' } as unknown as HomeStatsSummary + }, + ['host-1', 'host-2', 'host-3'] + ) + + expect(total).toEqual(stats()) + }) +}) diff --git a/mobile/src/stats/home-stats-total.ts b/mobile/src/stats/home-stats-total.ts new file mode 100644 index 00000000000..72264872561 --- /dev/null +++ b/mobile/src/stats/home-stats-total.ts @@ -0,0 +1,51 @@ +export type HomeStatsSummary = { + totalAgentsSpawned: number + totalPRsCreated: number + totalAgentTimeMs: number + firstEventAt: number | null +} + +/** + * Why: the home header shows one lifetime-usage row for every paired desktop. Each host answers + * stats.summary for itself, so a single shared slot made the row flip to whichever host replied + * last — visible churn now that every reconnect re-reads. Sum instead; one host still totals itself. + * + * Summing only `hostIds` keeps an unpaired desktop out of the total: replies are cached per host + * for the life of the process, so an entry outlives the host it describes. + */ +export function totalHomeStats( + byHost: Record, + hostIds: readonly string[] +): HomeStatsSummary | null { + const hosts = hostIds.filter((id) => id in byHost).map((id) => byHost[id]) + if (hosts.length === 0) { + return null + } + const total: HomeStatsSummary = { + totalAgentsSpawned: 0, + totalPRsCreated: 0, + totalAgentTimeMs: 0, + firstEventAt: null + } + for (const host of hosts) { + // The rows come straight off the wire unvalidated; a malformed desktop reply must not + // NaN out or crash the header for every other host. + if (!host || typeof host !== 'object') { + continue + } + total.totalAgentsSpawned += finiteOrZero(host.totalAgentsSpawned) + total.totalPRsCreated += finiteOrZero(host.totalPRsCreated) + total.totalAgentTimeMs += finiteOrZero(host.totalAgentTimeMs) + if (typeof host.firstEventAt === 'number' && Number.isFinite(host.firstEventAt)) { + total.firstEventAt = + total.firstEventAt === null + ? host.firstEventAt + : Math.min(total.firstEventAt, host.firstEventAt) + } + } + return total +} + +function finiteOrZero(value: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} diff --git a/mobile/src/transport/host-connect-refetch-gate.test.ts b/mobile/src/transport/host-connect-refetch-gate.test.ts new file mode 100644 index 00000000000..17b3d68ae24 --- /dev/null +++ b/mobile/src/transport/host-connect-refetch-gate.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { createHostConnectRefetchGate } from './host-connect-refetch-gate' +import type { ConnectionState } from './types' + +function refetchesFor(states: ConnectionState[]): number { + const gate = createHostConnectRefetchGate() + return states.filter((state) => gate.observe(state)).length +} + +describe('createHostConnectRefetchGate', () => { + it('fetches once on the first connect', () => { + expect(refetchesFor(['connecting', 'handshaking', 'connected'])).toBe(1) + }) + + it('fetches again after a dropped socket comes back', () => { + expect( + refetchesFor(['connected', 'reconnecting', 'connecting', 'handshaking', 'connected']) + ).toBe(2) + }) + + it('does not storm while the connection holds', () => { + expect(refetchesFor(['connected', 'connected', 'connected'])).toBe(1) + }) + + it('never fetches while the host stays down', () => { + expect(refetchesFor(['connecting', 'disconnected', 'reconnecting', 'auth-failed'])).toBe(0) + }) +}) diff --git a/mobile/src/transport/host-connect-refetch-gate.ts b/mobile/src/transport/host-connect-refetch-gate.ts new file mode 100644 index 00000000000..4ce2ba885fc --- /dev/null +++ b/mobile/src/transport/host-connect-refetch-gate.ts @@ -0,0 +1,18 @@ +import type { ConnectionState } from './types' + +// Why: a logical client survives socket drops, so screens that fetch once per client latched +// after the first connect and never refreshed after a background/handoff reconnect. Gate the +// refetch on the transition INTO 'connected' — one read per reconnect, no polling timer. +export function createHostConnectRefetchGate(): { + observe: (state: ConnectionState) => boolean +} { + let connected = false + return { + observe(state) { + const nowConnected = state === 'connected' + const crossedIntoConnected = nowConnected && !connected + connected = nowConnected + return crossedIntoConnected + } + } +} diff --git a/mobile/src/worktree/home-host-worktree-fetch.test.ts b/mobile/src/worktree/home-host-worktree-fetch.test.ts new file mode 100644 index 00000000000..5799de96366 --- /dev/null +++ b/mobile/src/worktree/home-host-worktree-fetch.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest' +import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate' +import type { RpcClient } from '../transport/rpc-client' +import { + createStableLogicalRpcClient, + LogicalClientCutoverError +} from '../transport/stable-logical-rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' +import { fetchHomeHostWorktreeInfo } from './home-host-worktree-fetch' +import type { HostWorktreeInfo } from './home-worktree-info' + +type FakeSession = { + client: RpcClient + calls: number + settle: (response: RpcResponse | Error) => void +} + +function fakeSession(): FakeSession { + const pending: Array<(response: RpcResponse | Error) => void> = [] + const fake: FakeSession = { + calls: 0, + settle(response: RpcResponse | Error) { + const next = pending.shift() + next?.(response) + }, + client: { + sendRequest: () => { + fake.calls += 1 + return new Promise((resolve, reject) => { + pending.push((response) => + response instanceof Error ? reject(response) : resolve(response) + ) + }) + }, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: (): ConnectionState => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + } + return fake +} + +// Drains the promise chain so a retry queued in a .catch has reached the transport. +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +function catalogResponse(count: number, active: number): RpcResponse { + const worktrees = Array.from({ length: count }, (_, index) => ({ + worktreeId: `wt-${index}`, + repo: 'orca', + branch: `branch-${index}`, + displayName: `Workspace ${index}`, + liveTerminalCount: 0, + status: index < active ? ('working' as const) : ('done' as const) + })) + return { ok: true, result: { worktrees } } as RpcResponse +} + +function infoStore() { + let state: Record = {} + return { + get current() { + return state + }, + setInfo(updater: (prev: Record) => Record) { + state = updater(state) + } + } +} + +const notDisposed = () => false + +describe('fetchHomeHostWorktreeInfo', () => { + it('keeps the last proven counts when the in-flight read rejects', async () => { + const store = infoStore() + const host = fakeSession() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(12, 2)) + await loaded + + const failed = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(new Error('socket closed mid-request')) + await failed + + expect(store.current['host-1']).toEqual({ + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: expect.objectContaining({ worktreeId: 'wt-0' }), + catalogUnavailable: true, + staleCounts: true, + // Age-stamped so the card can stop calling day-old counts "last known". + countsProvenAt: expect.any(Number) + }) + }) + + it('stamps when the host proved the counts', async () => { + const store = infoStore() + const host = fakeSession() + const before = Date.now() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(3, 1)) + await loaded + + const provenAt = store.current['host-1'].countsProvenAt + expect(provenAt).toBeGreaterThanOrEqual(before) + expect(provenAt).toBeLessThanOrEqual(Date.now()) + }) + + it('marks a host whose catalog never loaded as unavailable, not empty', async () => { + const store = infoStore() + const host = fakeSession() + + const failed = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle({ ok: false, error: { code: 'internal' } } as RpcResponse) + await failed + + expect(store.current['host-1']).toMatchObject({ + totalWorktrees: 0, + catalogUnavailable: true + }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + }) + + it('re-reads the catalog on reconnect and clears the stale flag', async () => { + const store = infoStore() + const host = fakeSession() + const gate = createHostConnectRefetchGate() + + const connect = async (response: RpcResponse | Error) => { + if (!gate.observe('connected')) { + return + } + const done = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(response) + await done + } + + await connect(catalogResponse(12, 2)) + // Socket dies mid-poll: counts survive, flagged stale. + const dropped = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(new Error('socket closed mid-request')) + await dropped + gate.observe('reconnecting') + expect(store.current['host-1'].staleCounts).toBe(true) + + await connect(catalogResponse(13, 3)) + + expect(store.current['host-1']).toMatchObject({ + totalWorktrees: 13, + activeCount: 3 + }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + expect(store.current['host-1'].catalogUnavailable).toBeUndefined() + // Two connects + the dropped poll: the gate must not re-read while the link holds. + expect(host.calls).toBe(3) + await connect(catalogResponse(13, 3)) + expect(host.calls).toBe(3) + }) + + it('re-reads through a relay→direct cutover, which never leaves connected', async () => { + const store = infoStore() + const relay = fakeSession() + const direct = fakeSession() + const logical = createStableLogicalRpcClient(relay.client, 'relay') + const gate = createHostConnectRefetchGate() + const gateFires: ConnectionState[] = [] + logical.onStateChange((state) => { + if (gate.observe(state)) { + gateFires.push(state) + } + }) + gate.observe(logical.getState()) + + const loaded = fetchHomeHostWorktreeInfo(logical, 'host-1', store.setInfo, notDisposed) + relay.settle(catalogResponse(12, 2)) + await loaded + + // The supervisor's direct probe migrates while a refresh is on the wire. + const interrupted = fetchHomeHostWorktreeInfo(logical, 'host-1', store.setInfo, notDisposed) + await logical.migrateTo(direct.client, 'lan') + await flush() + + expect(direct.calls).toBe(1) + direct.settle(catalogResponse(13, 3)) + await interrupted + + // The gate can't cover this: migrateTo republishes 'connected' from 'connected'. + expect(gateFires).toEqual([]) + expect(store.current['host-1']).toMatchObject({ totalWorktrees: 13, activeCount: 3 }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + expect(store.current['host-1'].catalogUnavailable).toBeUndefined() + }) + + it('gives up on a host that keeps cutting over so the card still reports the failure', async () => { + const store = infoStore() + const host = fakeSession() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(12, 2)) + await loaded + + const cutoverStorm = fetchHomeHostWorktreeInfo( + host.client, + 'host-1', + store.setInfo, + notDisposed + ) + for (let attempt = 0; attempt < 6; attempt += 1) { + host.settle(new LogicalClientCutoverError()) + await flush() + } + await cutoverStorm + + // 1 original + CUTOVER_RETRY_LIMIT retries, then the failure is surfaced. + expect(host.calls).toBe(4) + expect(store.current['host-1'].staleCounts).toBe(true) + }) + + it('ignores a response that lands after the screen is disposed', async () => { + const store = infoStore() + const host = fakeSession() + + const done = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, () => true) + host.settle(new Error('socket closed mid-request')) + await done + + expect(store.current['host-1']).toBeUndefined() + }) +}) diff --git a/mobile/src/worktree/home-host-worktree-fetch.ts b/mobile/src/worktree/home-host-worktree-fetch.ts new file mode 100644 index 00000000000..7c6dafe6b14 --- /dev/null +++ b/mobile/src/worktree/home-host-worktree-fetch.ts @@ -0,0 +1,79 @@ +import { setCachedWorktrees } from '../cache/worktree-cache' +import { sendSingleFlightRequest } from '../transport/request-single-flight' +import type { RpcClient } from '../transport/rpc-client' +import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { + markHomeWorktreeCatalogUnavailable, + type HomeWorktreeSummary, + type HostWorktreeInfo +} from './home-worktree-info' +import { pickResumeWorktree } from './resume-worktree' +import { WORKTREE_PS_FULL_LIMIT } from './worktree-catalog-snapshot-client' + +const ACTIVE_STATUSES = new Set(['working', 'active', 'permission']) +// Why: a relay↔direct cutover rejects in-flight reads without ever leaving 'connected', so the +// connect gate never re-arms. Re-issue on the replacement session; cap it so a migration loop +// can't spin. See runtime-capability-probe.ts for the same hazard on status.get. +const CUTOVER_RETRY_LIMIT = 2 + +export type HostWorktreeInfoSetter = ( + updater: (prev: Record) => Record +) => void + +/** Reads one host's worktree catalog for the Home card, preserving proven counts on failure. */ +export function fetchHomeHostWorktreeInfo( + client: RpcClient, + hostId: string, + setInfo: HostWorktreeInfoSetter, + disposed: () => boolean +): Promise { + const markUnavailable = (): void => { + setInfo((prev) => { + const current = prev[hostId] + const next = markHomeWorktreeCatalogUnavailable(current, hostId) + return next === current ? prev : { ...prev, [hostId]: next } + }) + } + + const attempt = (cutoverRetriesLeft: number): Promise => + sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT }) + .then((response) => { + if (disposed()) { + return + } + if (!response.ok) { + markUnavailable() + return + } + const result = response.result as { worktrees?: HomeWorktreeSummary[] } + const worktrees = result.worktrees ?? [] + setCachedWorktrees(hostId, worktrees) + const active = worktrees.filter((w) => w.status && ACTIVE_STATUSES.has(w.status)) + // Mirror the desktop's focused workspace (see pickResumeWorktree). + const lastActive = pickResumeWorktree(worktrees) + setInfo((prev) => ({ + ...prev, + [hostId]: { + hostId, + totalWorktrees: worktrees.length, + activeCount: active.length, + lastActiveWorktree: lastActive, + countsProvenAt: Date.now() + } + })) + }) + .catch((error: unknown) => { + if (disposed()) { + return + } + // A cutover raises only after migrateTo installed an authenticated replacement, so the + // read was interrupted, not answered — ask again instead of latching "Last known". + if (cutoverRetriesLeft > 0 && isLogicalClientCutoverError(error)) { + return attempt(cutoverRetriesLeft - 1) + } + // Any other rejection (socket died mid-request) is a failed refresh, not an empty host. + markUnavailable() + }) + + return attempt(CUTOVER_RETRY_LIMIT) +} diff --git a/mobile/src/worktree/home-worktree-info.test.ts b/mobile/src/worktree/home-worktree-info.test.ts index ebbfe379e99..5cb80d007dd 100644 --- a/mobile/src/worktree/home-worktree-info.test.ts +++ b/mobile/src/worktree/home-worktree-info.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { markHomeWorktreeCatalogUnavailable, type HostWorktreeInfo } from './home-worktree-info' +import { + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS, + homeHostWorktreeSummary, + markHomeWorktreeCatalogUnavailable, + type HostWorktreeInfo +} from './home-worktree-info' describe('markHomeWorktreeCatalogUnavailable', () => { it('marks a host unavailable when no catalog has loaded', () => { @@ -28,7 +33,8 @@ describe('markHomeWorktreeCatalogUnavailable', () => { expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toEqual({ ...current, - catalogUnavailable: true + catalogUnavailable: true, + staleCounts: true }) }) @@ -44,3 +50,73 @@ describe('markHomeWorktreeCatalogUnavailable', () => { expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toBe(current) }) }) + +describe('homeHostWorktreeSummary', () => { + const provenAt = 1_700_000_000_000 + const loaded: HostWorktreeInfo = { + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: null, + countsProvenAt: provenAt + } + + it('summarizes a freshly loaded catalog', () => { + expect(homeHostWorktreeSummary(loaded, provenAt)).toBe('12 worktrees · 2 active') + expect( + homeHostWorktreeSummary({ ...loaded, totalWorktrees: 1, activeCount: 0 }, provenAt) + ).toBe('1 worktree') + }) + + it('keeps showing the last proven counts after a failed refresh', () => { + const afterFailure = markHomeWorktreeCatalogUnavailable(loaded, 'host-1') + + expect(homeHostWorktreeSummary(afterFailure, provenAt + 30_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('keeps the last proven counts however long ago the refresh failed', () => { + const afterFailure = markHomeWorktreeCatalogUnavailable(loaded, 'host-1') + + // A session that stays connected past the live window then hits one failed refresh is the + // headline case: the counts are history, not nothing. + expect( + homeHostWorktreeSummary(afterFailure, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + 1) + ).toBe('Last known: 12 worktrees · 2 active') + expect(homeHostWorktreeSummary(afterFailure, provenAt + 3 * 86_400_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('stops calling aged-out counts live even while a refresh is only pending', () => { + // A snapshot rehydrated from a previous app session describes a host we have not reached yet. + expect(homeHostWorktreeSummary(loaded, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS)).toBe( + '12 worktrees · 2 active' + ) + expect( + homeHostWorktreeSummary(loaded, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + 1) + ).toBe('Last known: 12 worktrees · 2 active') + expect(homeHostWorktreeSummary(loaded, provenAt + 3 * 86_400_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('treats counts persisted before age stamping as no longer live', () => { + const preUpgrade: HostWorktreeInfo = { ...loaded, countsProvenAt: undefined } + + expect(homeHostWorktreeSummary(preUpgrade, provenAt)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + expect( + homeHostWorktreeSummary(markHomeWorktreeCatalogUnavailable(preUpgrade, 'host-1'), provenAt) + ).toBe('Last known: 12 worktrees · 2 active') + }) + + it('reports unavailable only when no catalog ever loaded', () => { + expect(homeHostWorktreeSummary(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toBe( + 'Worktree list unavailable' + ) + expect(homeHostWorktreeSummary(undefined)).toBeNull() + }) +}) diff --git a/mobile/src/worktree/home-worktree-info.ts b/mobile/src/worktree/home-worktree-info.ts index 867e266e89e..a417636dcaf 100644 --- a/mobile/src/worktree/home-worktree-info.ts +++ b/mobile/src/worktree/home-worktree-info.ts @@ -15,8 +15,17 @@ export type HostWorktreeInfo = { activeCount: number lastActiveWorktree: HomeWorktreeSummary | null catalogUnavailable?: boolean + // The counts are the last proven ones, kept across a failed refresh. + staleCounts?: boolean + // When the host last answered with these counts (ms epoch); absent in snapshots an older + // build persisted, which is why an unstamped count counts as expired rather than fresh. + countsProvenAt?: number } +// Why: the home snapshot is persisted across launches, so counts could otherwise be rendered as +// live while describing a workspace set proven days ago. Past this window they are only history. +export const HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS = 10 * 60_000 + export function markHomeWorktreeCatalogUnavailable( current: HostWorktreeInfo | undefined, hostId: string @@ -25,7 +34,9 @@ export function markHomeWorktreeCatalogUnavailable( return current } if (current) { - return { ...current, catalogUnavailable: true } + // Why: `current` predates this failure, so its counts are proven host truth — a dropped + // socket must not erase them, only flag them as no longer live. + return { ...current, catalogUnavailable: true, staleCounts: true } } return { hostId, @@ -35,3 +46,32 @@ export function markHomeWorktreeCatalogUnavailable( catalogUnavailable: true } } + +/** The host card's worktree line, or null when nothing is known yet. */ +export function homeHostWorktreeSummary( + info: HostWorktreeInfo | undefined, + now: number = Date.now() +): string | null { + if (!info) { + return null + } + // Why (STA-3123): a catalog that never loaded must not assert a count the host has not proven. + if (info.catalogUnavailable && !info.staleCounts) { + return 'Worktree list unavailable' + } + const counts = `${info.totalWorktrees} worktree${info.totalWorktrees === 1 ? '' : 's'}${ + info.activeCount > 0 ? ` · ${info.activeCount} active` : '' + }` + // Age bounds liveness, not the counts themselves: a failed refresh — or a rehydrated snapshot the + // host has not re-confirmed — is still the last thing it told us, so keep it and drop the claim + // that it is current. + return info.staleCounts || !provenRecently(info, now) ? `Last known: ${counts}` : counts +} + +function provenRecently(info: HostWorktreeInfo, now: number): boolean { + // A snapshot written before countsProvenAt existed has an unknowable age; treat it as expired. + return ( + typeof info.countsProvenAt === 'number' && + now - info.countsProvenAt <= HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + ) +} diff --git a/mobile/vitest.config.ts b/mobile/vitest.config.ts index 9e7e3ecc59d..e39f90eedf1 100644 --- a/mobile/vitest.config.ts +++ b/mobile/vitest.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ oxc: vitestOxcConfig, test: { environment: 'node', - include: ['src/**/*.test.ts'] + // .tsx too: component tests exist (react-test-renderer + mocked react-native) and were + // silently never collected, so render-level regressions shipped untested. + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'] } }) From e599d924bdb828d060ea23aec1e4185435768f70 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:27:28 -0700 Subject: [PATCH 051/101] fix(editor): stop Close All / Close Others freezing the renderer on large tab sets (#12404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): stop Cmd/Ctrl+Shift+T reopening a phantom editor tab `openFile` honored a recently-closed snapshot's `reopenId` whenever no open file currently held that id, without checking whether the same (path, mode, owner) was already open under a different id. Editor ids are not symmetrical across worktrees: the first worktree to open an absolute path gets the bare path as its id, and any other owner of the same path gets a namespaced `editor:::` id. So a snapshot captured under one shape could be restored while the live tab carried the other. When that happened `openFile` took the `existing` branch and mapped over `openFiles` looking for the stale id, matching nothing — no OpenFile was created or updated — yet it still set `activeFileId`, the per-worktree active id, the tab bar order and a unified tab entity to that stale id. The user got a tab rendering nothing and a dangling active editor. Resolve the id from the already-open file whenever one exists, so capture and restore agree on canonicalization. This is a no-op for the non-reopen path, where `resolveEditorFileIdForOwner` already returns `existing.id` via the identical predicate. * fix(editor): make Close All linear instead of cubic in tab count closeAllFiles resolved a reopen position for every closing tab by calling getRecentlyClosedTabPosition, which itself rescanned the worktree's tab-bar order and group membership with a nested find per entry. That made the whole bulk close O(N^3) in open editor tabs, and because it runs inside a synchronous zustand set() the renderer is blocked for the entire duration: measured against the real store, 500 tabs took 1.2s and 1000 tabs took 13.0s of frozen UI. Sessions that accumulate tabs — long SSH/relay work, a mirrored host republishing open files, a folder workspace opened wide — hit this on a single menu click. Hoist the per-tab rescans into one precomputed index. The new createRecentlyClosedTabPositionIndex builds tab-bar, unified-tab, and group lookups in a single linear pass over the worktree's state, then answers each position query in O(1); getRecentlyClosedTabPosition now delegates to it so single-tab callers keep identical behavior. Positions, reopen order, and the tab-bar/group indices recorded by #12236 are unchanged — first-occurrence wins everywhere, matching the indexOf/find scans this replaces. Position derivation moves to its own module so recently-closed-tabs.ts stays under the max-lines limit without a suppression; existing importers are unaffected via re-export. Same measurement after the change: 500 tabs 44ms, 1000 tabs 142ms, and the instrumented scan count drops from 503,000,500 to 4,000 at N=1000. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- ...runtime-pty-latched-pane-retention.test.ts | 5 +- src/renderer/src/store/slices/editor.test.ts | 60 +++++++ src/renderer/src/store/slices/editor.ts | 5 +- .../slices/recently-closed-tab-position.ts | 167 ++++++++++++++++++ .../src/store/slices/recently-closed-tabs.ts | 121 ++----------- 5 files changed, 247 insertions(+), 111 deletions(-) create mode 100644 src/renderer/src/store/slices/recently-closed-tab-position.ts diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts index e9c83bf6718..5892ee2c19d 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts @@ -274,9 +274,8 @@ describe('remote runtime pty latched-pane retention', () => { it('does not stack listeners, registry entries or timers across repeated revive cycles', async () => { vi.useFakeTimers() try { - const { retryAllRemoteRuntimePtyRecoveriesNow } = await import( - './remote-runtime-pty-recovery-state' - ) + const { retryAllRemoteRuntimePtyRecoveriesNow } = + await import('./remote-runtime-pty-recovery-state') const transport = await attachStalePane(0) await vi.advanceTimersByTimeAsync(66_000) expect(transport.getRecoveryState?.().phase).toBe('disconnected') diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index a3c572612a4..8b87ee976c7 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -101,6 +101,22 @@ function ownedEditorFileId( return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}` } +/** Counts how often store code reads `entityId` off a worktree's unified tabs — + * a scan-count proxy that pins complexity without timing the wall clock. */ +function withCountedEntityIdReads(tabs: readonly Tab[], onRead: () => void): Tab[] { + return tabs.map((tab) => { + const { entityId, ...rest } = tab + return Object.defineProperty(rest, 'entityId', { + get: () => { + onRead() + return entityId + }, + enumerable: true, + configurable: true + }) as Tab + }) +} + function mirroredEditorUnifiedTab(id: string, entityId: string, worktreeId: string): Tab { return { id, @@ -1717,6 +1733,50 @@ describe('createEditorSlice recently closed editor tabs', () => { expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([firstId]) }) + it('captures close-all snapshot positions without rescanning tab state per closed tab', () => { + const countEntityIdReads = (fileCount: number): number => { + const store = createEditorTabsStore() + for (let index = 0; index < fileCount; index += 1) { + store.getState().openFile( + { + filePath: `/repo/file-${index}.ts`, + relativePath: `file-${index}.ts`, + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }, + { preview: false } + ) + } + let reads = 0 + store.setState({ + unifiedTabsByWorktree: { + 'wt-1': withCountedEntityIdReads( + store.getState().unifiedTabsByWorktree['wt-1'] ?? [], + () => { + reads += 1 + } + ) + } + } as Partial) + reads = 0 + + store.getState().closeAllFiles() + + expect(store.getState().openFiles).toHaveLength(0) + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']?.[0]?.position).toEqual({ + tabBarIndex: 0, + groupId: expect.any(String), + groupIndex: 0 + }) + return reads + } + + // Why: resolving each closed tab's position by rescanning tab order and group membership + // made close-all cubic — 4x the tabs cost ~64x the scans and froze the renderer for seconds. + expect(countEntityIdReads(80)).toBeLessThanOrEqual(countEntityIdReads(20) * 8) + }) + it('reactivates the live tab when a stale reopen id targets an already-open file', () => { const store = createEditorTabsStore() store.setState({ diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index be768183cac..bf17d61a3ea 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import { + createRecentlyClosedTabPositionIndex, getRecentlyClosedTabPosition, restoreRecentlyClosedTabPosition, pushRecentlyClosedTabKind @@ -2462,6 +2463,8 @@ export const createEditorSlice: StateCreator = (s const closingFiles = s.openFiles.filter((f) => f.worktreeId === activeWorktreeId) let nextRecentClosed = s.recentlyClosedEditorTabsByWorktree[activeWorktreeId] ?? [] let capturedCloseCount = 0 + // Why: one shared index — a per-file position lookup rescans tab order and group membership, making close-all cubic. + const positionIndex = createRecentlyClosedTabPositionIndex(s, activeWorktreeId) for (const f of [...closingFiles].toReversed()) { // Why: skip untitled non-dirty files (deleted from disk after close) and ephemeral preview tabs so the reopen stack has no vanished/junk paths. if ( @@ -2471,7 +2474,7 @@ export const createEditorSlice: StateCreator = (s continue } const { id: _id, isDirty: _dirty, mirroredFromRuntimeSession: _mirrored, ...snap } = f - const position = getRecentlyClosedTabPosition(s, activeWorktreeId, f.id) + const position = positionIndex.positionFor(f.id) nextRecentClosed = [ { ...(snap as ClosedEditorTabSnapshot), diff --git a/src/renderer/src/store/slices/recently-closed-tab-position.ts b/src/renderer/src/store/slices/recently-closed-tab-position.ts new file mode 100644 index 00000000000..69a144534c5 --- /dev/null +++ b/src/renderer/src/store/slices/recently-closed-tab-position.ts @@ -0,0 +1,167 @@ +import type { AppState } from '../types' + +export type RecentlyClosedTabPosition = { + tabBarIndex?: number + groupId?: string + groupIndex?: number +} + +/** Reusable position lookup for one worktree. Bulk closes resolve a position per + * closed tab; without a shared index every lookup rescans tab order and group + * membership, which makes close-all cubic in the tab count. */ +export type RecentlyClosedTabPositionIndex = { + positionFor: (entityId: string) => RecentlyClosedTabPosition | undefined +} + +export function createRecentlyClosedTabPositionIndex( + state: Pick, + worktreeId: string +): RecentlyClosedTabPositionIndex { + const tabBarOrder = state.tabBarOrderByWorktree?.[worktreeId] + const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? [] + const groups = state.groupsByWorktree?.[worktreeId] ?? [] + + // First occurrence wins everywhere, matching the `indexOf`/`find` scans this replaces. + const tabBarIndexByEntityId = new Map() + const tabByEntityId = new Map() + const tabById = new Map() + for (const tab of unifiedTabs) { + if (!tabByEntityId.has(tab.entityId)) { + tabByEntityId.set(tab.entityId, tab) + } + if (!tabById.has(tab.id)) { + tabById.set(tab.id, tab) + } + } + const tabBarEntityIdsByGroupId = new Map() + tabBarOrder?.forEach((entityId, index) => { + if (!tabBarIndexByEntityId.has(entityId)) { + tabBarIndexByEntityId.set(entityId, index) + } + const tab = tabByEntityId.get(entityId) + if (!tab?.groupId) { + return + } + const bucket = tabBarEntityIdsByGroupId.get(tab.groupId) + if (bucket) { + bucket.push(tab.entityId) + } else { + tabBarEntityIdsByGroupId.set(tab.groupId, [tab.entityId]) + } + }) + + const groupById = new Map() + const tabOrderIndexByGroupId = new Map>() + const groupOrderMatchesTabBarByGroupId = new Map() + for (const group of groups) { + if (groupById.has(group.id)) { + continue + } + groupById.set(group.id, group) + const indexByTabId = new Map() + const groupTabEntityIds = group.tabOrder.map((tabId, index) => { + if (!indexByTabId.has(tabId)) { + indexByTabId.set(tabId, index) + } + return tabById.get(tabId)?.entityId + }) + tabOrderIndexByGroupId.set(group.id, indexByTabId) + const tabBarGroupEntityIds = tabBarEntityIdsByGroupId.get(group.id) ?? [] + groupOrderMatchesTabBarByGroupId.set( + group.id, + groupTabEntityIds.length === tabBarGroupEntityIds.length && + groupTabEntityIds.every((entityId, index) => entityId === tabBarGroupEntityIds[index]) + ) + } + + return { + positionFor: (entityId) => { + const tabBarIndex = tabBarOrder ? (tabBarIndexByEntityId.get(entityId) ?? -1) : -1 + const unifiedTab = tabByEntityId.get(entityId) + const group = unifiedTab ? groupById.get(unifiedTab.groupId) : undefined + const groupIndex = + group && unifiedTab ? (tabOrderIndexByGroupId.get(group.id)?.get(unifiedTab.id) ?? -1) : -1 + if (tabBarIndex < 0 && (!group || groupIndex < 0)) { + return undefined + } + const groupOrderMatchesTabBar = + !group || (groupOrderMatchesTabBarByGroupId.get(group.id) ?? true) + return { + ...(tabBarIndex >= 0 && groupOrderMatchesTabBar ? { tabBarIndex } : {}), + ...(group && groupIndex >= 0 ? { groupId: group.id, groupIndex } : {}) + } + } + } +} + +export function getRecentlyClosedTabPosition( + state: Pick, + worktreeId: string, + entityId: string +): RecentlyClosedTabPosition | undefined { + return createRecentlyClosedTabPositionIndex(state, worktreeId).positionFor(entityId) +} + +export function insertTabAtRecentlyClosedPosition( + order: readonly string[], + tabId: string, + position?: RecentlyClosedTabPosition +): string[] { + const nextOrder = order.filter((id) => id !== tabId) + const index = position?.tabBarIndex + if (index === undefined) { + return [...nextOrder, tabId] + } + nextOrder.splice(Math.min(Math.max(index, 0), nextOrder.length), 0, tabId) + return nextOrder +} + +export function restoreRecentlyClosedTabPosition( + getState: () => Pick< + AppState, + | 'tabBarOrderByWorktree' + | 'groupsByWorktree' + | 'unifiedTabsByWorktree' + | 'setTabBarOrder' + | 'reorderUnifiedTabs' + >, + worktreeId: string, + entityId: string, + position?: RecentlyClosedTabPosition +): void { + if (!position) { + return + } + const state = getState() + const order = state.tabBarOrderByWorktree?.[worktreeId] + if (order && typeof state.setTabBarOrder === 'function') { + state.setTabBarOrder(worktreeId, insertTabAtRecentlyClosedPosition(order, entityId, position)) + } + + if (position?.groupIndex === undefined) { + return + } + const unifiedTab = (getState().unifiedTabsByWorktree?.[worktreeId] ?? []).find( + (candidate) => + candidate.entityId === entityId && + (position.groupId === undefined || candidate.groupId === position.groupId) + ) + if (!unifiedTab) { + return + } + const group = (getState().groupsByWorktree?.[worktreeId] ?? []).find( + (candidate) => candidate.id === unifiedTab.groupId + ) + if (!group) { + return + } + if (typeof getState().reorderUnifiedTabs === 'function') { + getState().reorderUnifiedTabs( + group.id, + insertTabAtRecentlyClosedPosition(group.tabOrder, unifiedTab.id, { + tabBarIndex: position.groupIndex + }), + { recordInteraction: false } + ) + } +} diff --git a/src/renderer/src/store/slices/recently-closed-tabs.ts b/src/renderer/src/store/slices/recently-closed-tabs.ts index 7faebebfa4f..d7904eb6c8f 100644 --- a/src/renderer/src/store/slices/recently-closed-tabs.ts +++ b/src/renderer/src/store/slices/recently-closed-tabs.ts @@ -5,114 +5,21 @@ import { isWindowsAbsolutePathLike, relativePathInsideRoot } from '../../../../shared/cross-platform-path' +import { + restoreRecentlyClosedTabPosition, + type RecentlyClosedTabPosition +} from './recently-closed-tab-position' -export type RecentlyClosedTabPosition = { - tabBarIndex?: number - groupId?: string - groupIndex?: number -} - -export function getRecentlyClosedTabPosition( - state: Pick, - worktreeId: string, - entityId: string -): RecentlyClosedTabPosition | undefined { - const tabBarOrder = state.tabBarOrderByWorktree?.[worktreeId] - const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? [] - const tabBarIndex = tabBarOrder?.indexOf(entityId) ?? -1 - const unifiedTab = unifiedTabs.find((tab) => tab.entityId === entityId) - const group = unifiedTab - ? (state.groupsByWorktree?.[worktreeId] ?? []).find( - (candidate) => candidate.id === unifiedTab.groupId - ) - : undefined - const groupIndex = group?.tabOrder.indexOf(unifiedTab?.id ?? '') ?? -1 - const groupTabEntityIds = group - ? group.tabOrder.map((tabId) => unifiedTabs.find((tab) => tab.id === tabId)?.entityId) - : [] - const tabBarGroupEntityIds = group - ? (tabBarOrder ?? []) - .map((tabId) => unifiedTabs.find((tab) => tab.entityId === tabId)) - .filter((tab) => tab?.groupId === group.id) - .map((tab) => tab?.entityId) - : [] - const groupOrderMatchesTabBar = - !group || - (groupTabEntityIds.length === tabBarGroupEntityIds.length && - groupTabEntityIds.every((entityId, index) => entityId === tabBarGroupEntityIds[index])) - if (tabBarIndex < 0 && (!group || groupIndex < 0)) { - return undefined - } - - return { - ...(tabBarIndex >= 0 && groupOrderMatchesTabBar ? { tabBarIndex } : {}), - ...(group && groupIndex >= 0 ? { groupId: group.id, groupIndex } : {}) - } -} - -export function insertTabAtRecentlyClosedPosition( - order: readonly string[], - tabId: string, - position?: RecentlyClosedTabPosition -): string[] { - const nextOrder = order.filter((id) => id !== tabId) - const index = position?.tabBarIndex - if (index === undefined) { - return [...nextOrder, tabId] - } - nextOrder.splice(Math.min(Math.max(index, 0), nextOrder.length), 0, tabId) - return nextOrder -} - -export function restoreRecentlyClosedTabPosition( - getState: () => Pick< - AppState, - | 'tabBarOrderByWorktree' - | 'groupsByWorktree' - | 'unifiedTabsByWorktree' - | 'setTabBarOrder' - | 'reorderUnifiedTabs' - >, - worktreeId: string, - entityId: string, - position?: RecentlyClosedTabPosition -): void { - if (!position) { - return - } - const state = getState() - const order = state.tabBarOrderByWorktree?.[worktreeId] - if (order && typeof state.setTabBarOrder === 'function') { - state.setTabBarOrder(worktreeId, insertTabAtRecentlyClosedPosition(order, entityId, position)) - } - - if (position?.groupIndex === undefined) { - return - } - const unifiedTab = (getState().unifiedTabsByWorktree?.[worktreeId] ?? []).find( - (candidate) => - candidate.entityId === entityId && - (position.groupId === undefined || candidate.groupId === position.groupId) - ) - if (!unifiedTab) { - return - } - const group = (getState().groupsByWorktree?.[worktreeId] ?? []).find( - (candidate) => candidate.id === unifiedTab.groupId - ) - if (!group) { - return - } - if (typeof getState().reorderUnifiedTabs === 'function') { - getState().reorderUnifiedTabs( - group.id, - insertTabAtRecentlyClosedPosition(group.tabOrder, unifiedTab.id, { - tabBarIndex: position.groupIndex - }), - { recordInteraction: false } - ) - } -} +export { + createRecentlyClosedTabPositionIndex, + getRecentlyClosedTabPosition, + insertTabAtRecentlyClosedPosition, + restoreRecentlyClosedTabPosition +} from './recently-closed-tab-position' +export type { + RecentlyClosedTabPosition, + RecentlyClosedTabPositionIndex +} from './recently-closed-tab-position' /** Snapshot of a terminal tab captured at user-initiated close time. Reopen * recreates a fresh shell in the same startup directory (Ghostty semantics) — From d8e5944b60e63a56346e27156bce4edff52bb966 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:34:22 -0700 Subject: [PATCH 052/101] Stop a duplicate headless orca serve from crash-looping and exhausting AppImage FUSE mounts (#12212) * fix(startup): stop a duplicate headless serve from crash-looping and leaking AppImage mounts A second Orca launch that loses the single-instance lock called app.quit() before `ready`. That quit is deferred, so the doomed process kept booting into Chromium's Linux display initialization, failed with "Missing X server or $DISPLAY", and died with SIGSEGV. systemd read that as a crash and restarted it forever; each restart re-mounted the AppImage and left the squashfuse mount behind, until the host hit the 1000-mount FUSE ceiling and every later launch failed. The lock-losing launch now calls app.exit(3), which terminates synchronously before any display init. Exit code 3 is a stable "another process already owns this userData profile" contract, and the documented systemd unit uses RestartPreventExitStatus=3 plus a real StartLimitIntervalSec/StartLimitBurst window so a permanently failing launch can no longer retry unbounded. Second-instance argv is now forwarded to the owner, and a duplicate `orca serve` no longer asks the live headless server to open a desktop window. Desktop activation for ordinary launches and macOS dock re-activation is unchanged. Closes #11935 * docs(headless): clear the start limit before the scripted service starts StartLimitIntervalSec=300/StartLimitBurst=5 rate-limits operator starts too, so after a crash-loop trips the burst systemd refuses a plain `systemctl start` for the rest of the window. The Upgrade and Roll back scripts run under `set -euo pipefail`, so that refusal aborted the rollback mid-flight and left the server down on the exact recovery path the doc prescribes. Both scripts (and their EXIT-trap recoveries) now run `systemctl reset-failed` first, the unit reference explains the interaction, and the crash-loop bullet points at it for manual starts. Co-authored-by: Orca * test(startup): reproduce the #11935 duplicate-serve crash loop under real Electron The committed coverage for #11935 was source-text greps, so nothing gated the mechanism the fix rests on: pre-`ready` `app.quit()` is deferred, which is why the lock-losing headless `orca serve` kept booting into Linux display init. This runs two real Electron processes against one disposable profile. The duplicate executes the lock-loss gate's own `app.*` statement, lifted out of `src/main/index.ts`, so reverting to `app.quit()` fails the test. It also feeds the owner's real forwarded argv through `shouldActivateDesktopForSecondInstance`. Also record why the activation predicate matches `--serve` and not the `serve` subcommand: an AppImage launched as `orca serve` exits at the CLI redirect before requesting the lock. * test(startup): wait for the owner process to exit before removing its profile Windows holds the profile's handles for a beat after SIGKILL, so an immediate rmSync can fail with EBUSY/EPERM. Co-authored-by: Orca * test(startup): pass the fixture marker path by env, not argv Chromium reorders argv and the duplicate's argv is itself under test, so a trailing positional was the wrong channel for it. Co-authored-by: Orca * test(startup): only the activation case waits on the owner notification The exit-contract cases assert on the duplicate's own already-terminated process, so they should not block on cross-process delivery. Co-authored-by: Orca * test(startup): drop the staged lock race, keep the real-Electron gate contract CI proved the two-process form cannot work on a display-less Linux runner: Chromium's ProcessSingleton needs the browser IO thread, which needs `ready`, which needs a display. The pre-`ready` owner looked stale and the duplicate took the lock (`expected [ 'DUPLICATE_WON_LOCK' ] to include 'DUPLICATE_LOST_LOCK'`). Lock acquisition and argv forwarding are already covered in single-instance-lock.test.ts. What only a real process can settle is what the loser does next, so that is all this file now runs -- display-independent. Co-authored-by: Orca --------- Co-authored-by: Orca --- docs/reference/headless-linux-server.md | 44 ++++++- src/main/index.ts | 15 ++- ...single-instance-lock-exit.electron.test.ts | 115 ++++++++++++++++++ ...single-instance-lock-headless-exit.test.ts | 84 +++++++++++++ src/main/startup/single-instance-lock.test.ts | 29 ++++- src/main/startup/single-instance-lock.ts | 18 ++- 6 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 src/main/startup/single-instance-lock-exit.electron.test.ts create mode 100644 src/main/startup/single-instance-lock-headless-exit.test.ts diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index cdee2a4ca41..2284bcdaa23 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -155,6 +155,8 @@ display `:99` when no display exists: Description=Orca runtime server After=network-online.target Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=5 [Service] Type=simple @@ -165,6 +167,7 @@ ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100. StandardOutput=journal StandardError=journal Restart=on-failure +RestartPreventExitStatus=3 RestartSec=5 [Install] @@ -174,6 +177,19 @@ WantedBy=multi-user.target Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that clients should use. +Exit status `3` means another process already owns this userData profile, so +`RestartPreventExitStatus=3` stops the unit instead of retrying a launch that +cannot succeed. Any other permanent startup fault is capped at 5 starts per +5 minutes; systemd's defaults (10s window, 5 starts) can never trip at +`RestartSec=5`, which is how one bad launch could restart thousands of times. +The start limit counts operator-initiated starts too, so once it trips systemd +refuses a plain `systemctl start` until the 5-minute window rolls over. Run +`sudo systemctl reset-failed orca-serve.service` first to clear it — the +[Upgrade](#upgrade-steps) and [Roll back](#roll-back) scripts already do. +On systemd older than 230 those two directives are spelled +`StartLimitInterval=`/`StartLimitBurst=` and belong in `[Service]`; Ubuntu +20.04, Orca's oldest supported base, ships systemd 245. + Enable the service: ```bash @@ -228,6 +244,8 @@ Then add the display dependency to the Orca service: Description=Orca runtime server After=network-online.target orca-xvfb.service Wants=network-online.target orca-xvfb.service +StartLimitIntervalSec=300 +StartLimitBurst=5 [Service] Type=simple @@ -237,6 +255,7 @@ Environment=DISPLAY=:99 Environment=LIBGL_ALWAYS_SOFTWARE=1 ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20 Restart=on-failure +RestartPreventExitStatus=3 RestartSec=5 [Install] @@ -415,6 +434,8 @@ recover_failed_upgrade() { sudo rm -f /opt/orca/orca-linux.AppImage.recovering \ /opt/orca/VERSION.recovering if ((recovery_ok)); then + # A tripped StartLimitBurst refuses a plain start + sudo systemctl reset-failed orca-serve.service || true sudo systemctl start orca-serve.service || true else echo 'Upgrade recovery failed; service remains stopped' >&2 @@ -482,6 +503,8 @@ sudo mv "$ORCA_ROLLBACK_NEW" "$ORCA_ROLLBACK" ORCA_BINARY_PROMOTED=1 sudo mv -f /opt/orca/orca-linux.AppImage.new /opt/orca/orca-linux.AppImage sudo mv -f /opt/orca/VERSION.new /opt/orca/VERSION +# Clears a start-limit hit left by the version being replaced +sudo systemctl reset-failed orca-serve.service sudo systemctl start orca-serve.service ORCA_SERVICE_STOPPED=0 trap - EXIT @@ -614,6 +637,8 @@ restart_after_rollback_error() { fi fi if ((recovery_ok)); then + # A tripped StartLimitBurst refuses a plain start + sudo systemctl reset-failed orca-serve.service || true sudo systemctl start orca-serve.service || true else echo 'Rollback recovery failed; service remains stopped' >&2 @@ -714,6 +739,8 @@ if ((ORCA_ROLLBACK_HAS_VERSION)); then else sudo rm -f /opt/orca/VERSION fi +# The crash-looping build you are rolling back from tripped StartLimitBurst +sudo systemctl reset-failed orca-serve.service sudo systemctl start orca-serve.service ORCA_SERVICE_STOPPED=0 sudo rm -rf -- "$ORCA_RESTORE" @@ -805,9 +832,24 @@ refuse to run there and print the command to run on the machine you want. `orca` user and that `/opt/orca` is readable by that user. - Clients cannot connect: make sure `--pairing-address` is an address reachable from the client, and make sure firewalls allow the selected `--port`. +- Journal shows `Another Orca instance is already running for this userData + profile` and the unit exits `3`: another process already owns the profile, so + `RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the + owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or + keep it and leave the unit down), then run + `sudo systemctl reset-failed orca-serve && sudo systemctl start orca-serve` — + `reset-failed` clears the failed state and any start-limit counter. If no owner + exists, the lock is stale (Chromium recorded a pid that + has since been reused): remove `SingletonLock` and `SingletonSocket` from the + userData directory and start again. If an earlier crash-loop already leaked + AppImage mounts, list them with `findmnt -rn -t fuse.orca-linux.AppImage` and + release only the ones with no live owner using `fusermount -uz ` (or + `umount -l `), leaving the running instance's mount alone. - Service crash-loops right after an upgrade: use [Roll back](#roll-back) with the pre-upgrade `.ready` bundle. Do not rerun the upgrade first; doing so would - make the crashing version the next rollback binary. + make the crashing version the next rollback binary. The loop trips + `StartLimitBurst`, so any manual `systemctl start` outside that script needs + `sudo systemctl reset-failed orca-serve.service` first. - Diagnosing other missing libraries: extract the AppImage without launching it with `./orca-linux.AppImage --appimage-extract`, then run `ldd squashfs-root/orca` to list any shared libraries the host is missing. diff --git a/src/main/index.ts b/src/main/index.ts index 39fc076be0a..7ed25c33dc9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -160,8 +160,10 @@ import { acquireSingleInstanceLock, logSingleInstanceLockBypass, logSingleInstanceLockFailure, + shouldActivateDesktopForSecondInstance, shouldBypassSingleInstanceLock, - shouldSkipSingleInstanceLock + shouldSkipSingleInstanceLock, + SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from './startup/single-instance-lock' import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' @@ -606,7 +608,11 @@ function focusExistingWindow(): void { }) } -function requestDesktopActivation(): void { +function requestDesktopActivation(argv: readonly string[] = []): void { + // Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935). + if (!shouldActivateDesktopForSecondInstance(argv)) { + return + } desktopActivationGate.requestActivation() } @@ -726,10 +732,11 @@ if (startupDiagnosticsEnabled) { if (!hasSingleInstanceLock) { // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. logSingleInstanceLockFailure() - app.quit() + // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). + app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) } -// Why: when another process holds the lock we've already quit; skip file-writing side effects so this transient process never touches userData. +// Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData. if (hasSingleInstanceLock) { // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. const shouldCoupleToDevParent = is.dev && !isServeMode diff --git a/src/main/startup/single-instance-lock-exit.electron.test.ts b/src/main/startup/single-instance-lock-exit.electron.test.ts new file mode 100644 index 00000000000..7cf61a4af0f --- /dev/null +++ b/src/main/startup/single-instance-lock-exit.electron.test.ts @@ -0,0 +1,115 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from './single-instance-lock' + +// Why #11935: the lock-loss gate runs before Electron `ready`, where `app.quit()` is deferred, so a +// duplicate headless `orca serve` kept executing the rest of startup, reached Linux Ozone/X11 init +// with no display, died with SIGSEGV, and systemd restarted it until the leaked AppImage FUSE mounts +// hit the kernel's 1000-mount ceiling. This runs the gate's own termination statement, lifted out of +// `src/main/index.ts`, under the real Electron binary. +// +// Why not a live lock race: Chromium's Linux ProcessSingleton only answers a second process once the +// browser IO thread is up, which needs `ready` and therefore a display. On a display-less CI runner +// the "owner" is treated as stale and the duplicate takes the lock, so the race cannot be staged +// there. Lock acquisition and argv forwarding are covered in `single-instance-lock.test.ts`; what +// only a real process can settle is what the loser does next, which is what this file pins. + +const electronBinary = createRequire(import.meta.url)('electron') as string +const LOCK_LOST = 'LOCK_LOST' +const CONTINUED_INTO_STARTUP = 'CONTINUED_INTO_STARTUP' +const REACHED_TAIL = 'REACHED_TAIL' +const MARKER_ENV = 'ORCA_LOCK_FIXTURE_MARKER' + +const fixtureRoots: string[] = [] + +afterAll(() => { + for (const root of fixtureRoots) { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +/** The `app.*` call the shipped lock-loss gate executes, so a revert to `app.quit()` fails here. */ +function readLockLossTermination(): string { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const start = source.indexOf('if (!hasSingleInstanceLock) {') + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf('\n}', start) + expect(end).toBeGreaterThan(start) + + return source + .slice(start, end) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('app.')) + .join('\n') +} + +function buildFixtureMain(termination: string): string { + return [ + `const { app } = require('electron')`, + `const { appendFileSync } = require('node:fs')`, + // Why: the marker path travels by env — Chromium rewrites argv before the main script sees it. + `const marker = process.env.${MARKER_ENV}`, + `const mark = (name) => appendFileSync(marker, name + '\\n')`, + `const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = ${SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE}`, + `mark('${LOCK_LOST}')`, + termination, + `mark('${CONTINUED_INTO_STARTUP}')`, + // Why: stand in for the rest of `src/main/index.ts`, which on the reported host was display init. + `mark('${REACHED_TAIL}')`, + `process.exit(0)` + ].join('\n') +} + +type FixtureRun = { status: number | null; markers: string[] } + +function runLockLossGate(termination: string): FixtureRun { + const root = mkdtempSync(join(tmpdir(), 'orca-lock-loss-')) + fixtureRoots.push(root) + const dir = join(root, 'fixture') + const marker = join(root, 'markers.log') + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'package.json'), + '{ "name": "orca-lock-loss-fixture", "main": "main.js" }' + ) + writeFileSync(join(dir, 'main.js'), buildFixtureMain(termination)) + writeFileSync(marker, '') + + const result = spawnSync(electronBinary, [dir, '--no-sandbox'], { + stdio: 'ignore', + timeout: 60_000, + env: { ...process.env, [MARKER_ENV]: marker } + }) + expect(result.error).toBeUndefined() + + return { + status: result.status, + markers: readFileSync(marker, 'utf8').split('\n').filter(Boolean) + } +} + +describe('#11935 pre-ready lock-loss termination under real Electron', () => { + it('stops the duplicate launch before any further startup runs, with the already-running code', () => { + const termination = readLockLossTermination() + // Why: an empty slice would let the fixture fall through to its own exit and pass vacuously. + expect(termination).not.toBe('') + + const run = runLockLossGate(termination) + + expect(run.markers).toEqual([LOCK_LOST]) + expect(run.status).toBe(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) + }, 90_000) + + it('reproduces the deferred graceful quit that let the doomed launch keep booting', () => { + const run = runLockLossGate('app.quit()') + + // Why: pins the Electron semantic the fix rests on — pre-`ready` `quit()` schedules, it does not stop. + expect(run.markers).toEqual([LOCK_LOST, CONTINUED_INTO_STARTUP, REACHED_TAIL]) + expect(run.status).not.toBe(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) + }, 90_000) +}) diff --git a/src/main/startup/single-instance-lock-headless-exit.test.ts b/src/main/startup/single-instance-lock-headless-exit.test.ts new file mode 100644 index 00000000000..3c9468f595e --- /dev/null +++ b/src/main/startup/single-instance-lock-headless-exit.test.ts @@ -0,0 +1,84 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from './single-instance-lock' + +// Why #11935: a pre-`ready` graceful quit is deferred, so a lock-losing headless `orca serve` +// kept booting into Linux Ozone/X11 init, died with SIGSEGV, and systemd restarted it forever +// until the leaked AppImage FUSE mounts hit the kernel's 1000-mount ceiling. + +function readSystemdUnitBlocks(doc: string): Map { + const blocks = new Map() + // Why: key on the unit's path comment — splitting on directives mixes `[Unit]` and `[Service]` across blocks. + for (const match of doc.matchAll(/^# \/etc\/systemd\/system\/(\S+\.service)$/gm)) { + const start = match.index + match[0].length + const name = match[1] + blocks.set(name, [...(blocks.get(name) ?? []), doc.slice(start, doc.indexOf('```', start))]) + } + return blocks +} + +describe('headless lock-loss exit contract', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const doc = readFileSync(join(process.cwd(), 'docs/reference/headless-linux-server.md'), 'utf8') + + it('exits the lock-losing launch immediately instead of scheduling a graceful quit', () => { + const gateStart = source.indexOf('if (!hasSingleInstanceLock) {') + // Why: bound the anchor — an unresolved indexOf slices to EOF and passes vacuously. + expect(gateStart).toBeGreaterThanOrEqual(0) + const gateEnd = source.indexOf('\n}', gateStart) + expect(gateEnd).toBeGreaterThan(gateStart) + + const gate = source.slice(gateStart, gateEnd) + expect(gate).toContain('app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)') + expect(gate).not.toContain('app.quit()') + }) + + it('keeps a duplicate serve launch from promoting the live server to a desktop window', () => { + const activationStart = source.indexOf('function requestDesktopActivation(') + expect(activationStart).toBeGreaterThanOrEqual(0) + const activationEnd = source.indexOf('\n}', activationStart) + expect(activationEnd).toBeGreaterThan(activationStart) + + expect(source.slice(activationStart, activationEnd)).toContain( + 'shouldActivateDesktopForSecondInstance(argv)' + ) + }) + + it('makes every documented serve unit treat a duplicate owner as terminal', () => { + const serveUnits = readSystemdUnitBlocks(doc).get('orca-serve.service') ?? [] + + expect(serveUnits.length).toBeGreaterThan(0) + for (const unit of serveUnits) { + expect(unit).toContain( + `RestartPreventExitStatus=${SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE}` + ) + expect(unit).toContain('StartLimitIntervalSec=') + expect(unit).toContain('StartLimitBurst=') + } + }) + + it('clears the start limit before every scripted start, which a tripped burst would refuse', () => { + const lines = doc.split('\n') + const startLines = lines.flatMap((line, index) => + /^\s*sudo systemctl start orca-serve/.test(line) ? [index] : [] + ) + + expect(startLines.length).toBeGreaterThan(0) + for (const index of startLines) { + expect(lines.slice(Math.max(0, index - 3), index).join('\n')).toContain( + 'systemctl reset-failed orca-serve' + ) + } + }) + + it('leaves the Xvfb unit free to self-heal from a transient display flap', () => { + const xvfbUnits = readSystemdUnitBlocks(doc).get('orca-xvfb.service') ?? [] + + expect(xvfbUnits.length).toBeGreaterThan(0) + // Why: a start limit here would down the display unit permanently and take orca-serve with it. + for (const unit of xvfbUnits) { + expect(unit).not.toContain('StartLimitBurst=') + } + }) +}) diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index 5ccd70e6a4a..b3058c91a66 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -4,8 +4,10 @@ import { acquireSingleInstanceLock, logSingleInstanceLockBypass, logSingleInstanceLockFailure, + shouldActivateDesktopForSecondInstance, shouldBypassSingleInstanceLock, shouldSkipSingleInstanceLock, + SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE, SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE, SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE } from './single-instance-lock' @@ -56,11 +58,11 @@ describe('acquireSingleInstanceLock', () => { expect(acquired).toBe(true) expect(fake.requestSingleInstanceLock).toHaveBeenCalledTimes(1) expect(fake.on).toHaveBeenCalledTimes(1) - expect(fake.on).toHaveBeenCalledWith('second-instance', onSecondInstance) + expect(fake.on).toHaveBeenCalledWith('second-instance', expect.any(Function)) expect(fake.listeners['second-instance']).toHaveLength(1) }) - it('fires the registered callback when second-instance dispatches', () => { + it('forwards the second launch argv so the owner can decide whether to activate', () => { const onSecondInstance = vi.fn() const fake = makeFakeApp(true) @@ -68,9 +70,30 @@ describe('acquireSingleInstanceLock', () => { const [registered] = fake.listeners['second-instance'] ?? [] expect(registered).toBeDefined() - registered?.() + registered?.({}, ['/opt/orca/orca-linux.AppImage', '--serve'], '/home/orca') expect(onSecondInstance).toHaveBeenCalledTimes(1) + expect(onSecondInstance).toHaveBeenCalledWith(['/opt/orca/orca-linux.AppImage', '--serve']) + }) +}) + +describe('shouldActivateDesktopForSecondInstance', () => { + it('ignores a duplicate serve launch but still activates for a desktop launch', () => { + // Why: a supervisor respawning `orca serve` must not open a window on a display-less host (#11935). + const serveArgv = ['/opt/orca/orca-linux.AppImage', '--serve'] + expect(shouldActivateDesktopForSecondInstance(serveArgv)).toBe(false) + expect(shouldActivateDesktopForSecondInstance(['/Applications/Orca.app/orca'])).toBe(true) + }) + + it('fails open when no argv is available', () => { + expect(shouldActivateDesktopForSecondInstance([])).toBe(true) + expect(shouldActivateDesktopForSecondInstance()).toBe(true) + }) +}) + +describe('SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE', () => { + it('stays 3 because the documented systemd unit keys RestartPreventExitStatus off it', () => { + expect(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE).toBe(3) }) }) diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 078500284a2..4a08fa61e73 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -7,6 +7,17 @@ export const SINGLE_INSTANCE_LOCK_BYPASS_ENV = 'ORCA_BYPASS_SINGLE_INSTANCE_LOCK export const SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV = 'ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK' export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE = '[single-instance] ORCA_BYPASS_SINGLE_INSTANCE_LOCK=1 is set; bypassing the packaged macOS single-instance lock for diagnostics. Do not use this with another Orca instance running for the same profile.' +// Why: stable "another process owns this profile" contract that systemd RestartPreventExitStatus= keys off; changing it silently un-fixes #11935. +export const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = 3 + +// Why: `serve` is a CLI subcommand, never Electron argv — an AppImage launched as `orca serve` exits +// at the CLI redirect before requesting the lock, and the CLI re-spawns the Electron child with `--serve`. +const SERVE_MODE_ARG = '--serve' + +// Why: a duplicate `orca serve` is a supervisor artifact, not a user asking for a window; fail open when argv is unavailable. +export function shouldActivateDesktopForSecondInstance(argv: readonly string[] = []): boolean { + return !argv.includes(SERVE_MODE_ARG) +} /** * Why: Orca writes two canonical discovery files into `/`: @@ -26,11 +37,14 @@ export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE = * way dev (`orca-dev` userData) and packaged (`orca` userData) runs lock in * separate namespaces instead of serialising against each other. */ -export function acquireSingleInstanceLock(app: App, onSecondInstance: () => void): boolean { +export function acquireSingleInstanceLock( + app: App, + onSecondInstance: (argv: readonly string[]) => void +): boolean { if (!app.requestSingleInstanceLock()) { return false } - app.on('second-instance', onSecondInstance) + app.on('second-instance', (_event, argv) => onSecondInstance(argv)) return true } From ac7f9a4fe1dfe40b4e8dafad175df7e9e869a17d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 23:48:18 -0700 Subject: [PATCH 053/101] fix(pty): require proven absence before retiring a stable pane owner (#12393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pty): require proven absence before retiring a stable pane owner "Session not found" only proves the provider we asked has no such PTY. A degraded router answers unmapped session ids from the local fallback, which never owned a daemon session, so a live agent's PTY produced the same error a dead one does — and the pane was then given a synthetic exit, its durable pane->PTY binding deleted, and a duplicate spawned. For a single-pane tab the retirement also drops the tab from persisted state, which the renderer cannot add back. Gate the retirement on probePtyLiveness, which polls every possible owner and answers false only when all of them agree the session is absent. `null` (nobody could answer) is not absence. Providers without a probe are their own sole owner, so their refusal stays authoritative and their behavior is unchanged. Also move the retirement's durability barrier off writeToDiskSync: it fsyncs the whole multi-MB state from the main thread, and a restore that retired N dead panes paid that stall N times. * fix(pty): make the unproven-absence veto transient and route attaches to the real owner Review found the proven-absence gate correct in direction but terminal in effect: "probe says alive" and "probe says unknown" both became a hard spawn failure with no consumer, no retry, and a raw internal token in a toast. - Graceful teardown (should-fix): a session being killed reports alive (getAppliedSize answers while isAlive, and isTerminating does not flip _state) while createOrAttach already throws SessionNotFoundError, so for up to KILL_TIMEOUT_MS the pane failed instead of retiring the doomed binding. The veto now re-proves on a bounded 6s budget: the moment any owner answers "absent" the pane retires and spawns fresh exactly as before. - Unprovable owners (should-fix): DaemonPtyAdapter.probePtyLiveness never called ensureConnected, so a merely disconnected adapter answered null forever and one null poisons probePtyOwners; it also took no deadline, so a wedged daemon burned the client's 30s request timeout per attach. It now connects first (like listProcesses) and threads an absolute deadline through the fan-out. The thrown error is user-legible English instead of terminal_pane_owner_unverified. - Durability barrier (should-fix): flushPendingOrThrowAsync is not a twin of flushOrThrow — it defaults to the drain-to-stable-generation loop the sibling best-effort caller deliberately avoids, adds active-view and GitHub sidecar writes, re-serializes state per iteration, and rejects when writes are frozen where the sync path no-ops. Reverted to flushOrThrow, matching the sibling retire paths in orca-runtime. - Misrouting root cause (nit): DegradedDaemonFreshSpawnRouter.spawn sent an unmapped sessionId to the local fallback and DaemonPtyRouter.spawn sent it to the current daemon, both faking "Session not found" for a live session. Both now resolve an existing owner first (the same resolution every non-spawn path already uses); minted ids still route to the fallback/current daemon. The pane reattaches instead of only surviving. Tests: retry-until-proven, teardown-window retire, misroute-then-reattach, probe deadline threading, unconnected-adapter probe, and both routers' owner resolution. Each fails with only the source reverted. * fix(pty): drop the follow-up, keep the minimal proven-absence guard Reverts aa27eb5274 and leaves only the original guard: retire a stable pane's owner solely when a provider authoritatively answers absent (probePtyLiveness === false). `true` and `null` still refuse to authorize destruction, and providers without a probe keep their refusal authoritative, so their behavior is unchanged. The follow-up bought too much surface for a P0, and two of its pieces did not hold up: - The `hasPty` owner scan added to DaemonPtyRouter.spawn and the degraded fresh-spawn router is inert for the case it targeted. `hasPty` reads `activeSessionIds`, which only spawn/attach populate — `listProcesses` (daemon-pty-adapter.ts) never does. After a failed discovery the scan therefore cannot find the owner it was written to find. - Its two routing tests could not fail: the mocks backed `hasPty` and `listProcesses` with one shared array, giving the fake adapter knowledge the real DaemonPtyAdapter provably lacks. Also dropped: the bounded 6s retry loop and its constants, the sentinel symbol, the exported user-facing message, the `opts.deadlineMs` widening of probePtyLiveness and its threading through the probe/routers, and the ensureConnected change in DaemonPtyAdapter. Kept from the follow-up: the flushOrThrow revert. flushPendingOrThrowAsync is not a like-for-like twin (drain-to-stable-generation loop, sidecar writes, per-iteration serialize, rejects when writes are frozen), so retirePersistedStablePaneOwner stays synchronous. One gap the strict one-shot gate does open is closed here. `getSize` landed in daemon protocol v18, and legacy adapters are created for every version in PREVIOUS_DAEMON_PROTOCOL_VERSIONS (1..30). A pre-v18 daemon error-replies "Unknown request type", probePtyLiveness catches it as `null`, and one `null` makes probePtyOwners unprovable forever — so a genuinely dead pane could never retire. probePtyLiveness now version-gates getSize and asks such a daemon for `listSessions` instead, the same inventory legacy discovery already routes by, requested directly rather than through listProcesses (which swallows errors into an empty list) so an unreachable socket still answers `null`. Unreachable never means absent; only an owner that answered does. * fix(daemon): detect getSize support instead of inferring it from the protocol number The version gate was not a sound boundary. `getSize` landed in 22b00a7cd2 while PROTOCOL_VERSION was already 18 and that commit did not bump it, so version 18 means two different things: daemons built before it lack the request, daemons built after it have it. Both report 18. A daemon from that window therefore passed the `>= 18` gate, was sent `getSize`, error-replied "Unknown request type", and answered `null` — and one `null` makes the owner fan-out permanently unprovable, so a genuinely dead pane could never retire. That is the same wedge the gate was added to close, just narrowed to one build window. Ask the daemon what it supports rather than trusting its number: attempt `getSize`, and fall back to `listSessions` only when the daemon itself replies that the request type is unknown. The predicate is deliberately narrow so a transient failure stays unproven instead of being mistaken for a missing capability, and the rejection is remembered so later probes skip a round trip that cannot work. This mirrors the capability-probe rule AGENTS.md already requires for Git, for the same reason: version numbers overstate capabilities. Unreachable still never means absent. * test(pty): pin that proven absence still retires, and correct a wrong rationale Review found the guard had no positive-direction coverage: every existing retire test uses a provider with no probePtyLiveness, so the guard is skipped there. Mutating `!== false` into `!== undefined` — which turns the guard into a permanent veto, so no daemon-backed pane could ever recover from a genuinely dead owner — left the whole suite green. Pinned with a case that fails under exactly that mutation. Also corrects the fallback's Why-comment. It claimed listProcesses "swallows errors into an empty list"; it does not — DaemonPtyAdapter.listProcesses rethrows and the router fails closed. The error-swallowing lives in discoverLegacySessions / discoverDegradedDaemonSessions. Requesting listSessions directly is still right, but because a liveness probe should not publish inventory audit observations as a side effect — not for the stated reason. * fix(daemon): bound the liveness probe so a wedged daemon cannot stall a pane mount probePtyLiveness passed no timeout, so each request fell back to the client's 30s REQUEST_TIMEOUT_MS. A wedged daemon holds its socket open rather than refusing, so it answers neither quickly nor at all — and probePtyOwners awaits every adapter before it can conclude anything. One hung daemon therefore stalled each restoring pane for up to 30s before the pane reported that it could not reopen. Nearly all local terminals are daemon-backed, so this is the common configuration, not an edge case. Bound both probe requests to 2s. Answering "unknown" quickly is strictly better here than answering slowly: unknown never authorizes retirement, it only defers it, so the only thing a shorter budget can cost is an earlier retry. The tests now pin the bound at the call rather than leaving it implicit. * test(daemon): pin the getSize probe timeout, and reattach an orphaned comment Review proved a coverage gap by mutation: removing LIVENESS_PROBE_TIMEOUT_MS from the getSize request left the whole suite green, so nothing stopped that leg regressing to the client's 30s default. Only the listSessions leg was pinned. The assertion now fails under exactly that mutation. Also moves isUnknownRequestTypeError below isDaemonGoneError. It had been inserted between isDaemonGoneError and the comment describing it, so that comment read as documenting the wrong function. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/daemon/daemon-protocol-version.ts | 2 + src/main/daemon/daemon-pty-adapter.test.ts | 100 +++++++- src/main/daemon/daemon-pty-adapter.ts | 53 ++++- src/main/ipc/pty.test.ts | 253 +++++++++++++++++++++ src/main/ipc/pty.ts | 9 + 5 files changed, 412 insertions(+), 5 deletions(-) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 61cabf2fc15..fee06b235f2 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -6,6 +6,8 @@ export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31 export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30 export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27 export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11 +// Why: `getSize` landed in v18; older daemons reject it as an unknown request type. +export const GET_SIZE_PROTOCOL_VERSION = 18 export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25 export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26 export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26 diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 5dc807459be..84147ddb890 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -5,10 +5,11 @@ import { join } from 'node:path' import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { DaemonClient } from './client' import { DaemonProtocolError } from './daemon-errors' -import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter' import { COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, + GET_SIZE_PROTOCOL_VERSION, PROTOCOL_VERSION } from './daemon-protocol-version' import { DaemonServer } from './daemon-server' @@ -1204,6 +1205,103 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { await expect(adapter.probePtyLiveness('session')).resolves.toBeNull() }) + + function createProbeAdapter( + protocolVersion: number, + request: ReturnType + ): DaemonPtyAdapter { + const probeAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion }) + ;( + probeAdapter as unknown as { + client: { request: ReturnType; disconnect: ReturnType } + } + ).client = { request, disconnect: vi.fn() } + return probeAdapter + } + + // Why: a pre-v18 daemon rejects `getSize` as an unknown request type, so it would answer + // `null` forever — and one `null` makes the owner fan-out permanently unprovable, which + // would leave a genuinely dead pane unable to ever retire and respawn. + it('answers a pre-getSize daemon from its session inventory', async () => { + // Faithful pre-v18 daemon: routeRequest falls through its switch and error-replies. + const request = vi.fn(async (type: string) => { + if (type !== 'listSessions') { + throw new Error(`Unknown request type: ${type}`) + } + return { + sessions: [ + { sessionId: 'legacy-live', isAlive: true }, + { sessionId: 'legacy-exited', isAlive: false } + ] + } + }) + const legacy = createProbeAdapter(GET_SIZE_PROTOCOL_VERSION - 1, request) + + await expect(legacy.probePtyLiveness('legacy-live')).resolves.toBe(true) + await expect(legacy.probePtyLiveness('legacy-exited')).resolves.toBe(false) + await expect(legacy.probePtyLiveness('never-existed')).resolves.toBe(false) + expect(request).toHaveBeenCalledWith('listSessions', undefined, LIVENESS_PROBE_TIMEOUT_MS) + expect(request).not.toHaveBeenCalledWith('getSize', expect.anything()) + + legacy.dispose() + }) + + // The P0 boundary: an owner that cannot be reached must never read as one that answered "absent". + it('still answers unknown when a pre-getSize daemon cannot be reached', async () => { + const request = vi.fn(async () => { + throw new Error('Not connected') + }) + const legacy = createProbeAdapter(GET_SIZE_PROTOCOL_VERSION - 1, request) + + await expect(legacy.probePtyLiveness('legacy-live')).resolves.toBeNull() + + legacy.dispose() + }) + + // Why: `getSize` shipped into an already-released protocol without a version bump, so a + // daemon can report a version that implies support and still reject the request. Gating on + // the number alone left those daemons permanently unprovable — the same wedge, narrowed. + it('falls back when a daemon rejects getSize despite reporting a version that has it', async () => { + const request = vi.fn(async (type: string) => { + if (type === 'getSize') { + throw new Error(`Unknown request type: ${type}`) + } + return { sessions: [{ sessionId: 'ambiguous-live', isAlive: true }] } + }) + const ambiguous = createProbeAdapter(GET_SIZE_PROTOCOL_VERSION, request) + + await expect(ambiguous.probePtyLiveness('ambiguous-live')).resolves.toBe(true) + await expect(ambiguous.probePtyLiveness('never-existed')).resolves.toBe(false) + + // The rejection is remembered, so later probes skip the round trip that cannot work. + expect(request.mock.calls.filter(([type]) => type === 'getSize')).toHaveLength(1) + // Why pinned here: a wedged daemon holds its socket open, so an unbounded getSize would + // stall a pane mount for the client's 30s default instead of answering "unknown" in 2s. + expect(request).toHaveBeenCalledWith( + 'getSize', + { sessionId: 'ambiguous-live' }, + LIVENESS_PROBE_TIMEOUT_MS + ) + + ambiguous.dispose() + }) + + // The safety direction: only the daemon's own "I do not implement that" may switch strategy. + // A transient failure must stay unproven rather than be retried as a capability question. + it('keeps a transient getSize failure unproven instead of treating it as unsupported', async () => { + const request = vi.fn(async (type: string) => { + if (type === 'getSize') { + throw new Error('Connection lost') + } + return { sessions: [] } + }) + const flaky = createProbeAdapter(GET_SIZE_PROTOCOL_VERSION, request) + + await expect(flaky.probePtyLiveness('live-elsewhere')).resolves.toBeNull() + expect(request).not.toHaveBeenCalledWith('listSessions', undefined, LIVENESS_PROBE_TIMEOUT_MS) + + flaky.dispose() + }) }) describe('getBufferSnapshot', () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 2bba3c467dc..b9473c3de32 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -37,6 +37,7 @@ import { type TakePendingOutputResult } from './types' import { + GET_SIZE_PROTOCOL_VERSION, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION @@ -139,6 +140,12 @@ export type DaemonIdentityChangeEvent = { const MAX_TOMBSTONES = 1000 const MAX_CONCURRENT_CHECKPOINTS = 4 +// Why far below the client's 30s default: a wedged daemon holds its socket open, so an unbounded +// probe stalls a pane mount for the full request timeout — and the owner fan-out waits on every +// adapter, so one hung daemon stalls each restoring pane. Answering "unknown" quickly is strictly +// better here: unknown never authorizes retirement, it only defers it. +export const LIVENESS_PROBE_TIMEOUT_MS = 2_000 + // Why: providers take an absolute teardown deadline, but the client RPC takes a // relative timeout — convert only here, at the request itself, so sequential RPCs // naturally share the remaining budget (undefined keeps the client's 30s default). @@ -203,6 +210,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.sleepRestoreSessionIds.delete(sessionId) }) private activeSessionIds = new Set() + // Set only once this daemon has rejected `getSize` as unknown; its protocol number cannot prove it. + private getSizeUnsupported = false // A replacement daemon has none of the old PTYs; only createOrAttach can make their bindings writable again. private sessionsAwaitingDaemonRecovery = new Set() private sessionIncarnations = new Map() @@ -900,11 +909,37 @@ export class DaemonPtyAdapter implements IPtyProvider { async probePtyLiveness(id: string): Promise { try { - const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( - 'getSize', - { sessionId: id } + if (!this.getSizeUnsupported && this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION) { + try { + const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( + 'getSize', + { sessionId: id }, + LIVENESS_PROBE_TIMEOUT_MS + ) + return result.size !== null + } catch (error) { + // Why the capability probe rather than the version alone: `getSize` shipped into an + // already-released protocol without a bump, so a daemon can report a version that + // implies support and still reject the request. Ask what it can do, not what its + // number implies — and remember the answer so later probes skip the dead round trip. + if (!isUnknownRequestTypeError(error)) { + throw error + } + this.getSizeUnsupported = true + } + } + // Why: a daemon without `getSize` would otherwise answer `null` forever, and one `null` + // makes the whole owner fan-out unprovable — a dead pane could then never be retired. + // `listSessions` is the same inventory legacy discovery routes by, and has existed since + // the first daemon protocol. Requested directly rather than through `listProcesses` so a + // liveness probe does not publish inventory audit observations as a side effect; both + // rethrow on failure, so either way a dead socket stays `null` instead of reading absent. + const { sessions } = await this.client.request( + 'listSessions', + undefined, + LIVENESS_PROBE_TIMEOUT_MS ) - return result.size !== null + return sessions.some((session) => session.sessionId === id && session.isAlive) } catch { return null } @@ -2409,6 +2444,16 @@ function notifyAuditListeners(listeners: readonly ((value: T) => void)[], val } } +/** + * Narrow on purpose: only the daemon's own reply for a request type it does not implement. + * A transient failure must stay unproven rather than be mistaken for a missing capability. + * The server throws `Unknown request type: `; the client rejects with that text, which + * `addNodePtyRecoveryHint` only ever prepends to. + */ +function isUnknownRequestTypeError(err: unknown): boolean { + return err instanceof Error && err.message.includes('Unknown request type') +} + // Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall); // message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn. function isDaemonGoneError(err: unknown): boolean { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 257250a9858..797cfa4411f 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -9108,6 +9108,259 @@ 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 }) => { + 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') + } + 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(), + 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-unproven-owner' }] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-unproven-owner' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-unproven-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-unproven'), + preAllocateHandleForPty: vi.fn(() => 'term-unproven'), + 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 + ) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd, + command: 'codex resume unproven-owner-session', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + ).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 }) + expect(runtime.onPtyExit).not.toHaveBeenCalled() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(store.flushOrThrow).not.toHaveBeenCalled() + 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 () => { + const worktreeId = 'repo-1::/tmp/proven-absent-owner' + const cwd = '/tmp/proven-absent-owner' + const tabId = 'tab-proven-absent-owner' + const leafId = '78787878-7878-4878-8878-787878787878' + 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-proven-absent-owner') + } + 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(), + 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-proven-absent-owner' }] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-proven-absent-owner' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-proven-absent-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-proven-absent'), + preAllocateHandleForPty: vi.fn(() => 'term-proven-absent'), + 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 proven-absent-session', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + + 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({ + command: 'codex resume proven-absent-session' + }) + expect(runtime.onPtyExit).toHaveBeenCalledWith( + 'pty-proven-absent-owner', + 0, + 'inc-proven-absent-owner' + ) + expect(store.setWorkspaceSession).toHaveBeenCalledOnce() + expect(store.flushOrThrow).toHaveBeenCalledOnce() + }) + it('retires a dead owner from the exact SSH host session before fresh recovery', async () => { const connectionId = 'ssh-dead-stable-pane' const hostId = `ssh:${connectionId}` diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index cd01a985243..a59f70e8ac2 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -734,6 +734,15 @@ async function attachStablePaneOwner( 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 && From 29bfc1e22c33a971210089d020d87cbe410bda64 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:49:19 -0700 Subject: [PATCH 054/101] Fix Windows git/gh attribution wrappers rejecting a piped stdin argument (#12440) Co-authored-by: Orca --- ...al-attribution-win32-dash-argument.test.ts | 100 ++++++++++++++++++ src/main/attribution/terminal-attribution.ts | 23 +++- 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/main/attribution/terminal-attribution-win32-dash-argument.test.ts diff --git a/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts b/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts new file mode 100644 index 00000000000..810322a53c9 --- /dev/null +++ b/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts @@ -0,0 +1,100 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { applyTerminalAttributionEnv } from './terminal-attribution' + +// Why (#12046 follow-up): `powershell.exe -File