From b617aedf515322a6a2797feabaf83da2c6ed2d27 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:04:33 -0700 Subject: [PATCH] Handle spawn contact loss as unknown state, not retryable failure - When contact with the execution host breaks during spawn, loss of contact is not evidence the spawn failed; the host may have spawned the agent. Retain the pending operation and admission reservation for reconciliation to settle on real host evidence instead of settling the operation failed, which would let plain Retry cold-start a duplicate beside a live remote agent. - Rename availabilityCheck from 'host-preflight' to 'launch-reported': no executable-existence preflight runs anywhere; availability is established only by the launch itself on the execution host. Clients must not gate such rows on baseline detection. - Fix custom agent ID resolution in terminal pane operations (paste bracketing, ctrl-enter input encoding): these can be owned by custom IDs, but TUI_AGENT_CONFIG is keyed by built-in IDs. Resolve to base agent before indexing, falling back to baseline behavior when the catalog is orphaned. --- config/localization-coverage-allowlist.json | 8 ++ .../release-cut-token-permissions.test.mjs | 7 +- .../agent-catalog-projections.test.ts | 6 +- .../agent-launch/agent-catalog-projections.ts | 6 +- .../agent-launch-operation-store.test.ts | 9 ++ .../agent-launch-operation-store.ts | 8 ++ .../agent-launch-reconcile-runtime-deps.ts | 6 + .../agent-launch-spawn-contact-loss.test.ts | 47 ++++++++ .../agent-launch-spawn-contact-loss.ts | 34 ++++++ .../agent-launch-worktree-transaction.test.ts | 105 ++++++++++++++++++ .../agent-launch-worktree-transaction.ts | 40 ++++++- src/main/agent-launch/resolve-agent-launch.ts | 4 +- src/preload/api/pty-api.ts | 6 +- src/preload/index.ts | 6 +- .../custom-agent-catalog-entries.test.ts | 2 +- .../agent/custom-agent-catalog-entries.ts | 4 +- ...rce-control-agent-action-dialog-support.ts | 4 +- .../components/settings/agent-catalog-rows.ts | 2 +- .../tab-bar/tab-agent-launch-options.test.ts | 2 +- .../tab-bar/tab-agent-launch-options.ts | 4 +- .../terminal-agent-paste-bracketing.test.ts | 74 +++++++++++- .../terminal-agent-paste-bracketing.ts | 16 ++- .../terminal-pane/terminal-ctrl-enter.test.ts | 76 ++++++++++++- .../terminal-pane/terminal-ctrl-enter.ts | 12 +- .../TerminalQuickCommandDialog.test.tsx | 1 - .../lib/agent-catalog-custom-display.test.tsx | 3 +- .../src/lib/detected-agent-availability.ts | 2 +- .../source-control-agent-action-plan.test.ts | 20 ++-- .../lib/source-control-agent-action-plan.ts | 13 ++- .../host-session-mirror-settle-census.test.ts | 4 +- .../web-runtime-session-tab-actions.test.ts | 67 +++++++++-- ...time-session-terminal-agent-launch.test.ts | 13 ++- ...eb-runtime-session-terminal-create.test.ts | 20 +++- .../web-runtime-session-test-fixtures.ts | 6 +- .../src/web/web-agent-catalog-sync.test.ts | 2 +- .../src/web/web-agent-catalog-sync.ts | 7 +- src/shared/agent-catalog-snapshot.ts | 8 +- 37 files changed, 571 insertions(+), 83 deletions(-) create mode 100644 src/main/agent-launch/agent-launch-spawn-contact-loss.test.ts create mode 100644 src/main/agent-launch/agent-launch-spawn-contact-loss.ts diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 0ddb55bcde7..28cda2e4374 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -98,5 +98,13 @@ "dynamic": false, "count": 1, "reason": "aria-label shipped untranslated on main (#8142, agent status in terminal tabs); mirrors main's coverage posture — localize in a dedicated i18n pass, not this rebase." + }, + { + "filePath": "src/renderer/src/components/data-recovery/DataRecoveryPinExitCustomAgentExample.tsx", + "kind": "jsx-attribute:label", + "text": "Codex", + "dynamic": false, + "count": 1, + "reason": "'Codex' is the agent product name in a static aria-hidden picker mock; brand names are never localized." } ] diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index f2f544a8f27..fa4cd2570e8 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -5,13 +5,10 @@ import { parse } from 'yaml' const RELEASE_WORKFLOW = '.github/workflows/release-cut.yml' const EXPECTED_MATRIX = { - '.github/workflows/docs.yml#check': { contents: 'read' }, - '.github/workflows/docs.yml#production': { contents: 'read' }, - '.github/workflows/docs.yml#release_gate': { contents: 'read' }, '.github/workflows/e2e.yml#build': { contents: 'read' }, '.github/workflows/e2e.yml#changed-e2e': { contents: 'read' }, '.github/workflows/e2e.yml#e2e': { contents: 'read' }, - '.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' }, + '.github/workflows/e2e.yml#ssh-custom-agent': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' }, '.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' }, '.github/workflows/release-mac-build.yml#build-mac': { contents: 'write' }, @@ -19,7 +16,6 @@ const EXPECTED_MATRIX = { [`${RELEASE_WORKFLOW}#build-mac`]: { actions: 'write', contents: 'read' }, [`${RELEASE_WORKFLOW}#create-release`]: { contents: 'write' }, [`${RELEASE_WORKFLOW}#cut`]: { contents: 'write' }, - [`${RELEASE_WORKFLOW}#docs-production-dispatch`]: { actions: 'write' }, [`${RELEASE_WORKFLOW}#homebrew-bump`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#homebrew-bump -> .github/workflows/homebrew-bump.yml#bump-cask`]: { contents: 'read' @@ -31,7 +27,6 @@ const EXPECTED_MATRIX = { }, [`${RELEASE_WORKFLOW}#post-release-e2e`]: { actions: 'write' }, [`${RELEASE_WORKFLOW}#publish-release`]: { contents: 'write' }, - [`${RELEASE_WORKFLOW}#release-preflight`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#skill-sharing-linux-floor-release-gate`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#skill-sharing-release-gate`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#terminal-rendering-golden`]: { contents: 'read' }, diff --git a/src/main/agent-launch/agent-catalog-projections.test.ts b/src/main/agent-launch/agent-catalog-projections.test.ts index c7f14080eae..c919021a117 100644 --- a/src/main/agent-launch/agent-catalog-projections.test.ts +++ b/src/main/agent-launch/agent-catalog-projections.test.ts @@ -71,18 +71,18 @@ describe('remote snapshot projection', () => { expect(second).toMatchObject({ status: 'ready', envState: 'available', - availabilityCheck: 'host-preflight' + availabilityCheck: 'launch-reported' }) }) - it('uses host-preflight for a configured executable regardless of env', () => { + it('uses launch-reported for a configured executable regardless of env', () => { const snapshot = buildAgentCatalogSnapshot( settingsWith({ customTuiAgents: [liveAgent({ commandOverride: '/opt/codex' })] }) ) if ('code' in snapshot) { throw new Error('unexpected projection error') } - expect(snapshot.customAgents[0]).toMatchObject({ availabilityCheck: 'host-preflight' }) + expect(snapshot.customAgents[0]).toMatchObject({ availabilityCheck: 'launch-reported' }) }) it('projects valid-id repair rows without raw fields and omits malformed/duplicate rows', () => { diff --git a/src/main/agent-launch/agent-catalog-projections.ts b/src/main/agent-launch/agent-catalog-projections.ts index a64e3771225..2723e9a5c57 100644 --- a/src/main/agent-launch/agent-catalog-projections.ts +++ b/src/main/agent-launch/agent-catalog-projections.ts @@ -60,10 +60,12 @@ function syncedRow(definition: CustomTuiAgent): SyncedCustomTuiAgent { envState, // Conservative: a configured executable or host-applicable env means stock // baseline detection cannot vouch for this row, and naming the actual - // reason (e.g. PATH) would leak which env key exists. + // reason (e.g. PATH) would leak which env key exists. No executable + // existence check runs anywhere on the launch boundary — 'launch-reported' + // deliberately claims only that the launch itself is the availability check. availabilityCheck: definition.commandOverride || envState === 'available' - ? 'host-preflight' + ? 'launch-reported' : 'baseline-detection' } } diff --git a/src/main/agent-launch/agent-launch-operation-store.test.ts b/src/main/agent-launch/agent-launch-operation-store.test.ts index 2482f7909eb..b725f6d555c 100644 --- a/src/main/agent-launch/agent-launch-operation-store.test.ts +++ b/src/main/agent-launch/agent-launch-operation-store.test.ts @@ -149,6 +149,15 @@ describe('in-flight pending snapshots', () => { expect(store.getPending('token-1')).toBe(a) expect(store.getPending('token-2')).toBe(b) }) + + it('releaseSpawnInFlight drops only the in-flight guard, never the pending', () => { + const store = new AgentLaunchOperationStore() + store.beginPending(pending({ launchToken: 'token-x' })) + expect(store.isSpawnInFlight('token-x')).toBe(true) + store.releaseSpawnInFlight('token-x') + expect(store.isSpawnInFlight('token-x')).toBe(false) + expect(store.getPending('token-x')).not.toBeNull() + }) }) describe('settled ledger', () => { diff --git a/src/main/agent-launch/agent-launch-operation-store.ts b/src/main/agent-launch/agent-launch-operation-store.ts index e6af8a5e0d5..70c69708b1e 100644 --- a/src/main/agent-launch/agent-launch-operation-store.ts +++ b/src/main/agent-launch/agent-launch-operation-store.ts @@ -196,6 +196,14 @@ export class AgentLaunchOperationStore { return this.inFlightSpawnTokens.has(launchToken) } + /** Release only the in-flight spawn guard, KEEPING the pending snapshot: the + * contact-lost arm of the spawn transaction ends this process's spawn attempt + * without settling the operation, so reconciliation must become able to + * resolve the retained pending on real host evidence. */ + releaseSpawnInFlight(launchToken: string): void { + this.inFlightSpawnTokens.delete(launchToken) + } + getPending(launchToken: string): PendingAgentLaunchSnapshot | null { return this.pendingByToken.get(launchToken) ?? null } diff --git a/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts index 940811dd9cc..b3f34d5e2c8 100644 --- a/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts +++ b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts @@ -22,6 +22,12 @@ // user's Retry then spawns a duplicate beside it. Such hosts fall back to the // pre-launchToken identification, and hold the launch pending when that is // inconclusive. +// +// KNOWN GAP (pre-existing, out of custom-agent scope): token authority is read +// per-host here, but the SSH path withholds the token per-launch on a mutable +// probe that also collapses timeouts into `false` — so a recovered probe can read +// a live launch's missing echo as absence. Needs the delivery fact pinned at +// dispatch; always sending the token does not fix it (old relays drop it). import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' import { diff --git a/src/main/agent-launch/agent-launch-spawn-contact-loss.test.ts b/src/main/agent-launch/agent-launch-spawn-contact-loss.test.ts new file mode 100644 index 00000000000..39aa266dd78 --- /dev/null +++ b/src/main/agent-launch/agent-launch-spawn-contact-loss.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { isSpawnContactLossError } from './agent-launch-spawn-contact-loss' + +describe('isSpawnContactLossError', () => { + it('recognizes the SSH transport rejections that can interrupt a dispatched spawn', () => { + expect( + isSpawnContactLossError( + Object.assign(new Error('SSH connection lost, reconnecting...'), { + code: 'CONNECTION_LOST' + }) + ) + ).toBe(true) + expect( + isSpawnContactLossError( + Object.assign(new Error('Multiplexer disposed'), { code: 'DISPOSED' }) + ) + ).toBe(true) + expect( + isSpawnContactLossError( + Object.assign(new Error('Request "pty.spawn" timed out after 30000ms'), { + code: 'SSH_MUX_REQUEST_TIMEOUT' + }) + ) + ).toBe(true) + }) + + it('recognizes the established post-dispatch ambiguity marker', () => { + expect( + isSpawnContactLossError( + Object.assign(new Error('execution_owner_unavailable'), { + agentSessionOperationOutcome: 'unknown' as const + }) + ) + ).toBe(true) + }) + + it('treats host-attested spawn failures as attested, not contact loss', () => { + expect(isSpawnContactLossError(new Error('pty boom'))).toBe(false) + expect(isSpawnContactLossError(new Error('client_disconnected'))).toBe(false) + expect(isSpawnContactLossError(new Error('execution_owner_unavailable'))).toBe(false) + expect( + isSpawnContactLossError(Object.assign(new Error('other'), { code: 'SOMETHING_ELSE' })) + ).toBe(false) + expect(isSpawnContactLossError(null)).toBe(false) + expect(isSpawnContactLossError('CONNECTION_LOST')).toBe(false) + }) +}) diff --git a/src/main/agent-launch/agent-launch-spawn-contact-loss.ts b/src/main/agent-launch/agent-launch-spawn-contact-loss.ts new file mode 100644 index 00000000000..541738754c3 --- /dev/null +++ b/src/main/agent-launch/agent-launch-spawn-contact-loss.ts @@ -0,0 +1,34 @@ +// Classifies a launch-spawn rejection: did the execution host AFFIRMATIVELY +// report the spawn failed, or did contact with the host break while the request +// was (possibly) in flight? Per docs/reference/ssh-execution-boundary.md, loss +// of contact is never evidence of process death — the relay may have spawned +// the agent before the link dropped — so a contact-loss rejection must settle +// `launch_state_unknown` (pending retained, plain Retry blocked) rather than a +// retryable `spawn_failed` that would let Retry cold-start a duplicate beside a +// live remote agent. + +import { isSshMuxRequestTimeoutError } from '../ssh/ssh-channel-multiplexer' + +// Codes minted by createSshDisposalError (ssh-channel-multiplexer): every relay +// request pending when the link drops or the mux is disposed rejects with one +// of these. Neither observes the remote process — the spawn may have landed. +const SSH_DISPOSAL_ERROR_CODES = new Set(['CONNECTION_LOST', 'DISPOSED']) + +/** True when a spawn rejection cannot prove the execution host never spawned + * the agent. Recognizes the established post-dispatch ambiguity marker + * (`agentSessionOperationOutcome: 'unknown'`, stamped wherever the owning code + * already decided the outcome is unprovable) plus the SSH transport rejections + * that can interrupt a dispatched `pty.spawn` (disposal and request timeout). */ +export function isSpawnContactLossError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false + } + const marked = error as { agentSessionOperationOutcome?: unknown; code?: unknown } + if (marked.agentSessionOperationOutcome === 'unknown') { + return true + } + return ( + (typeof marked.code === 'string' && SSH_DISPOSAL_ERROR_CODES.has(marked.code)) || + isSshMuxRequestTimeoutError(error) + ) +} diff --git a/src/main/agent-launch/agent-launch-worktree-transaction.test.ts b/src/main/agent-launch/agent-launch-worktree-transaction.test.ts index 7621393ebb1..85fc3ba3389 100644 --- a/src/main/agent-launch/agent-launch-worktree-transaction.test.ts +++ b/src/main/agent-launch/agent-launch-worktree-transaction.test.ts @@ -18,6 +18,9 @@ import type { } from '../../shared/agent-launch-contract' import type { ExecuteAgentLaunchResult } from './agent-launch-boundary' import type { AdmissionPrincipal } from './agent-launch-admission-store' +import { retryRecoveryGateForFailureCode } from './agent-launch-reconciliation' +import { buildReconcileAgentLaunchDeps } from './agent-launch-reconcile-runtime-deps' +import { reconcileOnePendingAgentLaunch } from './agent-launch-worktree-reconcile-writer' const SNAPSHOT: AgentLaunchSnapshot = { version: 1, @@ -222,6 +225,108 @@ describe('runWorktreeAgentLaunchTransaction', () => { expect(persistFailure).toHaveBeenCalledTimes(1) }) + it('maps a mid-spawn contact loss to launch_state_unknown WITHOUT settling', async () => { + // Branch-review HIGH 1: a transport drop mid-spawn is not evidence the host + // never spawned the agent (ssh-execution-boundary). Settling spawn_failed + // would clear the pending and let Retry cold-start a duplicate. + const log: CallLog = [] + const spawn = vi.fn(async () => { + log.push('spawn') + throw Object.assign(new Error('SSH connection lost, reconnecting...'), { + code: 'CONNECTION_LOST' + }) + }) + const { deps, operationStore, settle, persistFailure } = makeDeps({ log, spawn }) + const outcome = await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT })) + ) + expect(outcome.status).toBe('failed') + if (outcome.status === 'failed') { + expect(outcome.failure).toMatchObject({ code: 'launch_state_unknown', intent: 'interactive' }) + } + // The admission reservation is NOT settled failed and the operation is NOT + // in the settled ledger — nothing has actually settled. + expect(settle).not.toHaveBeenCalled() + expect(operationStore.findSettledByIdempotencyKey('wt-1', 'idem-1')).toBeNull() + // The private pending survives for host-evidence reconciliation, but the + // in-flight guard is released so a reconcile pass may process the token. + expect(operationStore.getPending('tok-1')).not.toBeNull() + expect(operationStore.isSpawnInFlight('tok-1')).toBe(false) + // The durable card is written once; its code blocks the server-side retry + // gate, so no plain Retry can double-launch. + expect(persistFailure).toHaveBeenCalledTimes(1) + expect(retryRecoveryGateForFailureCode('launch_state_unknown')).toEqual({ + kind: 'launch_state_unknown' + }) + }) + + it('treats the post-dispatch ambiguity marker as contact loss', async () => { + const spawn = vi.fn(async () => { + throw Object.assign(new Error('execution_owner_unavailable'), { + agentSessionOperationOutcome: 'unknown' as const + }) + }) + const { deps, operationStore, settle } = makeDeps({ spawn }) + const outcome = await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT })) + ) + expect(outcome.status).toBe('failed') + if (outcome.status === 'failed') { + expect(outcome.failure.code).toBe('launch_state_unknown') + } + expect(settle).not.toHaveBeenCalled() + expect(operationStore.getPending('tok-1')).not.toBeNull() + }) + + it('reconciles the retained pending as launched when the token later proves live', async () => { + // End-to-end no-double-launch proof: contact loss keeps the pending; a + // provider-reconnect re-list that finds the token live ADOPTS the surviving + // terminal (settles launched) instead of ever spawning a second agent. + const spawn = vi.fn(async () => { + throw Object.assign(new Error('lost'), { code: 'CONNECTION_LOST' }) + }) + const { deps, operationStore } = makeDeps({ spawn }) + await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT })) + ) + const retained = operationStore.getPending('tok-1') + expect(retained).not.toBeNull() + + const arm = { + settleLaunched: vi.fn(), + settleFailed: vi.fn(), + markUnknown: vi.fn() + } + const settleBoundary = vi.fn((_token: string, _settlement: 'registered' | 'failed') => {}) + const reconcileDeps = buildReconcileAgentLaunchDeps({ + operationStore, + liveTerminalByToken: () => ({ ptyId: 'ssh-term-1', worktreeId: 'wt-1' }), + isHostAuthoritative: () => true, + isHostTokenAuthoritative: () => true, + expectedWorktreeId: (pending) => pending.scope, + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + }, + settleBoundary, + mintFailureId: () => 'fail-r1', + now: () => 2000 + }) + const outcome = reconcileOnePendingAgentLaunch(reconcileDeps, retained!) + expect(outcome).toEqual({ kind: 'launched' }) + expect(settleBoundary).toHaveBeenCalledWith('tok-1', 'registered') + expect(arm.settleLaunched).toHaveBeenCalledTimes(1) + expect(operationStore.getPending('tok-1')).toBeNull() + expect(operationStore.findSettledByIdempotencyKey('wt-1', 'idem-1')).toMatchObject({ + status: 'launched' + }) + }) + it('marks the token spawn-in-flight for the whole beginPending→settle window', async () => { // Regression (L4-M2): a reconcile pass firing while the spawn is running // must be able to skip this token — the PTY registers it only after spawn diff --git a/src/main/agent-launch/agent-launch-worktree-transaction.ts b/src/main/agent-launch/agent-launch-worktree-transaction.ts index bb1c76491a7..2fa989029e2 100644 --- a/src/main/agent-launch/agent-launch-worktree-transaction.ts +++ b/src/main/agent-launch/agent-launch-worktree-transaction.ts @@ -6,9 +6,12 @@ // synchronous write BEFORE the writer, so a crash mid-spawn still self- // identifies the terminal by token; // 3. spawn exactly ONE PTY from the resolved plan (token travels inside it); -// 4. settle — registered clears pending + records `launched`; any post-create -// failure keeps the workspace, writes a durable `agentLaunchFailure`, and -// records `failed`. No path spawns a substitute blank terminal (I9). +// 4. settle — registered clears pending + records `launched`; a host-attested +// post-create failure keeps the workspace, writes a durable +// `agentLaunchFailure`, and records `failed`. Loss of contact mid-spawn is +// NOT attested (the host may have spawned the agent): it keeps the pending +// and reservation, and writes launch_state_unknown instead of settling. +// No path spawns a substitute blank terminal (I9). // A request error performs no owner-state write. Electron-free and injectable. import type { AgentStartupPlan } from '../../shared/tui-agent-startup' @@ -19,6 +22,7 @@ import type { AgentLaunchRequestError, PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract' +import { isSpawnContactLossError } from './agent-launch-spawn-contact-loss' import type { TuiAgent } from '../../shared/types' import type { AgentLaunchBoundary, ExecuteAgentLaunchResult } from './agent-launch-boundary' import type { AdmissionPrincipal } from './agent-launch-admission-store' @@ -189,7 +193,35 @@ export async function runWorktreeAgentLaunchTransaction( try { const spawned = await deps.spawn(plan, receipt) terminalId = spawned.terminalId - } catch { + } catch (error) { + if (isSpawnContactLossError(error)) { + // Contact with the execution host broke while the spawn was (possibly) in + // flight — the host may well have spawned the agent (ssh-execution-boundary: + // a transport failure can only ever produce `unverifiable`). Settling + // `failed` here would clear the pending and hand the user a plain Retry + // that cold-starts a duplicate beside a live remote agent. Instead: keep + // the private pending + admission reservation (coexistence rule, exactly + // like a reconciled launch_state_unknown), release only the in-flight + // guard so provider-reconnect reconciliation may settle this token on real + // host evidence, and persist the non-retryable launch_state_unknown card + // (the server-side retry gate blocks on this code; the client card offers + // reconnect/forget, never plain Retry). No settled ledger entry: the + // operation has NOT settled. + deps.operationStore.releaseSpawnInFlight(receipt.launchToken) + const failure: PersistedAgentLaunchFailure = { + code: 'launch_state_unknown', + requestedAgent: receipt.requestedAgent, + baseAgent: receipt.baseAgent, + version: 1, + failureId: deps.mintFailureId(), + intent: params.intent, + occurredAt: nowFn() + } + // persistFailure also clears the public pending metadata; the durable card + // replaces it client-side while the private snapshot retains attribution. + deps.persistFailure(failure) + return { status: 'failed', failure } + } deps.boundary.settleAgentLaunch(receipt.launchToken, 'failed') // Settled ledger entry (inside persistedFailure) BEFORE clearPending: a // crash between the two durable writes must never lose both the pending diff --git a/src/main/agent-launch/resolve-agent-launch.ts b/src/main/agent-launch/resolve-agent-launch.ts index eb296609ea3..1465b91b6ae 100644 --- a/src/main/agent-launch/resolve-agent-launch.ts +++ b/src/main/agent-launch/resolve-agent-launch.ts @@ -318,7 +318,9 @@ export function resolveAgentLaunch( // Stock-name detection gates only stock catalog argv with no accepted user PATH // override; configured/custom prefixes and custom PATH env cannot be evaluated - // by name detection and proceed to preflight/spawn. + // by name detection and proceed straight to spawn — no executable-existence + // preflight exists, so for those rows the launch itself is the availability + // check ('launch-reported' in the catalog projection). if ( command.prefixSource === 'catalog' && request.detectedStockBaseAgents !== null && diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 012686b9c5e..621fc4e0780 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -12,8 +12,10 @@ import type { PtyRendererDeliveryStateReport } from '../../shared/pty-renderer-delivery-health' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' -import type { AgentLaunchNoticeCode } from '../../shared/agent-launch-contract' -import type { PersistedLaunchNoticeState } from '../../shared/agent-launch-contract' +import type { + AgentLaunchNoticeCode, + PersistedLaunchNoticeState +} from '../../shared/agent-launch-contract' import type { AgentLaunchInput, AgentLaunchSpawnOutcome diff --git a/src/preload/index.ts b/src/preload/index.ts index b7b402ef7ad..ba334e126cf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -438,7 +438,10 @@ import type { AgentLaunchVaultResumeDetailsResult, AgentLaunchVaultResumeEntry } from '../shared/agent-launch-spawn-request' -import type { AgentLaunchNoticeCode } from '../shared/agent-launch-contract' +import type { + AgentLaunchNoticeCode, + PersistedLaunchNoticeState +} from '../shared/agent-launch-contract' import type { ForgetUnknownAgentLaunchResult, WorktreeRetryAgentLaunchResult @@ -463,7 +466,6 @@ import type { MemorySnapshot } from '../shared/process-stats-types' import type { NestedRepoScanResult } from '../shared/project-group-types' import type { BaseRefDefaultResult, BaseRefSearchResult } from '../shared/repo-types' import type { TuiAgent } from '../shared/tui-agent' -import type { PersistedLaunchNoticeState } from '../shared/agent-launch-contract' import type { FloatingTerminalCwdRequest } from '../shared/ui-chrome-types' import type { UpdateStatus } from '../shared/update-status-types' import type { diff --git a/src/renderer/src/components/agent/custom-agent-catalog-entries.test.ts b/src/renderer/src/components/agent/custom-agent-catalog-entries.test.ts index 1f510e39266..6caabb6c88f 100644 --- a/src/renderer/src/components/agent/custom-agent-catalog-entries.test.ts +++ b/src/renderer/src/components/agent/custom-agent-catalog-entries.test.ts @@ -94,7 +94,7 @@ describe('mergeCustomAgentCatalogEntries', () => { expect(custom).toMatchObject({ cmd: '/opt/bin/agent', baseAgent: 'codex' }) }) - it('appends a host-preflighted custom whose base row is absent from the built-in list', () => { + it('appends a launch-reported custom whose base row is absent from the built-in list', () => { const merged = mergeCustomAgentCatalogEntries( [builtIn('claude', 'Claude', 'claude')], snapshot([ diff --git a/src/renderer/src/components/agent/custom-agent-catalog-entries.ts b/src/renderer/src/components/agent/custom-agent-catalog-entries.ts index b8ba0d25064..7cb9e439588 100644 --- a/src/renderer/src/components/agent/custom-agent-catalog-entries.ts +++ b/src/renderer/src/components/agent/custom-agent-catalog-entries.ts @@ -8,7 +8,7 @@ import type { TuiAgent } from '../../../../shared/types' * base harness like the settings catalog and the tab quick-launch surfaces. * * Oracle 35 gating: a baseline-stock custom is offered only when its base is - * detected; a configured-executable/custom-PATH custom is host-preflighted at + * detected; a configured-executable/custom-PATH custom is launch-reported at * launch and never client-gated. A null `detectedAgentIds` means detection is * unknown, so nothing is detection-gated (mirrors the built-in list). Adapted * rows carry the base harness id so AgentCombobox renders the base icon, and @@ -56,7 +56,7 @@ export function mergeCustomAgentCatalogEntries( placed.add(entry.id) } } - // A host-preflighted custom whose base row was filtered out (base disabled or + // A launch-reported custom whose base row was filtered out (base disabled or // undetected) is still launch-eligible, so append it rather than drop it. for (const [base, group] of customsByBase) { if (!placed.has(base)) { diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts index 95537e863df..b734522a952 100644 --- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts +++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts @@ -39,7 +39,7 @@ export function isSourceControlAgentUnavailable( } /** Gate on the resolved BASE (custom ids never index detection by their own id) and - * skip detection for host-preflight rows, which baseline stock detection can't evaluate. */ + * skip detection for launch-reported rows, which baseline stock detection can't evaluate. */ export function isSourceControlAgentDetectedAndEnabled( agent: TuiAgent | null, detectedAgents: TuiAgent[], @@ -50,7 +50,7 @@ export function isSourceControlAgentDetectedAndEnabled( return false } return ( - availability.availabilityClass === 'host-preflight' || + availability.availabilityClass === 'launch-reported' || detectedAgents.includes(availability.baseAgent) ) } diff --git a/src/renderer/src/components/settings/agent-catalog-rows.ts b/src/renderer/src/components/settings/agent-catalog-rows.ts index 1882bf8a9f6..6295c2fa6ca 100644 --- a/src/renderer/src/components/settings/agent-catalog-rows.ts +++ b/src/renderer/src/components/settings/agent-catalog-rows.ts @@ -24,7 +24,7 @@ import { } from '../../../../shared/agent-search-query' /** Effective status shown as a single quiet badge (plan §971). Desktop-local - * never surfaces `host-preflight`; that reason is withheld to paired clients. */ + * never surfaces `launch-reported`; that reason is withheld to paired clients. */ export type AgentCatalogRowStatus = | 'enabled' | 'disabled' diff --git a/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts b/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts index 5afd1719a2f..461e73ed6fe 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts @@ -119,7 +119,7 @@ describe('custom agents in the tab quick-launch list', () => { it('gates a baseline-stock custom on base detection but never gates a configured executable', () => { // Base not detected: the stock-PATH custom drops, the overridden one stays - // (its availability is host-preflighted at launch, oracle 35). + // (its availability is launch-reported, oracle 35). const stock = orderTabLaunchAgents(null, ['claude'], [customEntry]) expect(stock).not.toContain(CUSTOM_ID) const overridden = orderTabLaunchAgents( diff --git a/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts b/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts index e78b02852d3..9f92c5066eb 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts @@ -19,7 +19,7 @@ export type TabCustomAgentLaunchEntry = { commandOverride?: string /** Baseline-stock customs launch via the base's PATH binary, so they are * only offered when that base is detected; configured-executable and - * custom-PATH agents are host-preflighted at launch and never gated on + * custom-PATH agents are launch-reported (only the launch itself checks them) and never gated on * client detection (oracle 35). */ requiresDetectedBase: boolean } @@ -88,7 +88,7 @@ export function orderTabLaunchAgents( ordered.push(entry.id) } // Customs group under their base harness (the settings-catalog ordering); - // a host-preflighted custom is offered even when its base binary is not + // a launch-reported custom is offered even when its base binary is not // detected, since the base row's absence says nothing about its override. for (const custom of launchableCustoms) { if (custom.baseAgent === entry.id) { diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts index 986121934ed..3e20d98271d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { CustomTuiAgent } from '../../../../shared/types' import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' +import type { AppState } from '../../store/types' +import { useAppStore } from '../../store' import { resolveProtectedMultilinePasteOptionsForPane } from './terminal-agent-paste-bracketing' import { pasteTerminalText } from './terminal-bracketed-paste' import { @@ -158,6 +161,75 @@ describe('resolveProtectedMultilinePasteOptionsForPane', () => { }) }) +describe('custom-agent-owned panes', () => { + // Regression: agent-status rows and pty launch metadata keep the custom id + // unresolved, but TUI_AGENT_CONFIG is keyed by built-in ids — indexing raw + // read `windowsInputRecordPasteNewline` off undefined BEFORE the win32 guard, + // so every multiline paste threw on macOS/Linux too. + const CUSTOM_CODEX_ID = 'custom-agent:codex:3f9f1c22-2a1b-4c33-9a44-55d6e7f8a900' as const + const codexDerived: CustomTuiAgent = { + id: CUSTOM_CODEX_ID, + baseAgent: 'codex', + label: 'Codex (review)', + args: '', + env: {}, + syncEnv: false + } + let originalSettings: AppState['settings'] + + beforeEach(() => { + originalSettings = useAppStore.getState().settings + useAppStore.setState({ + settings: { + customTuiAgents: [codexDerived], + deletedCustomTuiAgents: [] + } as unknown as AppState['settings'] + }) + }) + + afterEach(() => { + useAppStore.setState({ settings: originalSettings }) + }) + + const customStatusRow = { + agentStatusByPaneKey: { [AGENT_PANE_KEY]: agentEntry({ agentType: CUSTOM_CODEX_ID }) } + } + + it('does not throw on a non-Windows host and brackets like its base', () => { + expect(() => decide(customStatusRow)).not.toThrow() + expect(decide(customStatusRow)).toEqual({ forceBracketedPasteForMultiline: true }) + }) + + it('brackets a process-confirmed custom foreground agent with no status row', () => { + const args = { + agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: { + [AGENT_PANE_KEY]: foregroundEntry({ agent: CUSTOM_CODEX_ID }) + } + } + expect(() => decide(args)).not.toThrow() + expect(decide(args)).toEqual({ forceBracketedPasteForMultiline: true }) + }) + + it('inherits the base agent Windows input-record newline', () => { + expect(decide({ hostPlatform: 'win32', ...customStatusRow })).toEqual({ + windowsInputRecordNewline: 'alt-enter' + }) + }) + + it('degrades to plain bracketing when the catalog lost the custom id', () => { + useAppStore.setState({ + settings: { + customTuiAgents: [], + deletedCustomTuiAgents: [] + } as unknown as AppState['settings'] + }) + expect(decide({ hostPlatform: 'win32', ...customStatusRow })).toEqual({ + forceBracketedPasteForMultiline: true + }) + }) +}) + describe('leading-newline paste into a remote agent pane', () => { // Regression: a mac client on a remote Windows host pasted a block starting with "\n". // ConPTY never forwarded DECSET 2004, so xterm rewrote the newline to CR and codex diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts index 6090ba6159a..7565245d4e6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts @@ -1,6 +1,11 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { + isBuiltInTuiAgent, + isTuiAgent, + TUI_AGENT_CONFIG +} from '../../../../shared/tui-agent-config' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { resolvePaneOwnerBaseAgent } from '../../lib/agent-base-identity' import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' import type { TerminalPasteTextOptions } from './terminal-paste-model' @@ -81,8 +86,13 @@ export function resolveProtectedMultilinePasteOptionsForAgentEvidence({ // measured live. An idle agent also sits at `done` past the 30-minute freshness // TTL, so neither state nor TTL can gate this either. A false negative sends the // user's parked draft; a false positive only changes encoding within this paste. - const windowsInputRecordPasteNewline = agent - ? TUI_AGENT_CONFIG[agent].windowsInputRecordPasteNewline + // Why base-resolved and guarded: TUI_AGENT_CONFIG is keyed by built-in ids only, + // and `agent` can be a custom id (agent-status rows and pty launch metadata keep + // it unresolved). Indexing raw threw on every multiline paste into a custom pane; + // a catalog-orphaned id keeps `agent` truthy and falls to plain bracketing below. + const baseAgent = resolvePaneOwnerBaseAgent(agent ?? undefined) + const windowsInputRecordPasteNewline = isBuiltInTuiAgent(baseAgent) + ? TUI_AGENT_CONFIG[baseAgent].windowsInputRecordPasteNewline : undefined if (hostPlatform === 'win32' && windowsInputRecordPasteNewline) { return { diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts index 27e0cce41bc..dd4aa014dd3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { CustomTuiAgent } from '../../../../shared/types' +import type { AppState } from '../../store/types' +import { useAppStore } from '../../store' import { hasCtrlEnterCsiUAuthorityForPane } from './terminal-ctrl-enter' const PANE_KEY = 'tab:pane' @@ -64,3 +67,74 @@ describe('hasCtrlEnterCsiUAuthorityForPane', () => { } }) }) + +describe('custom-agent foregrounds', () => { + // Regression: TUI_AGENT_CONFIG is keyed by built-in ids only — indexing with a + // custom foreground id read `ctrlEnterEncoding` off undefined and threw. + const CUSTOM_DROID_ID = 'custom-agent:droid:0f9f1c22-2a1b-4c33-9a44-55d6e7f8a901' as const + const CUSTOM_PI_ID = 'custom-agent:pi:0f9f1c22-2a1b-4c33-9a44-55d6e7f8a902' as const + + function derivedAgent( + id: CustomTuiAgent['id'], + baseAgent: CustomTuiAgent['baseAgent'] + ): CustomTuiAgent { + return { id, baseAgent, label: `${baseAgent} (derived)`, args: '', env: {}, syncEnv: false } + } + + let originalSettings: AppState['settings'] + + beforeEach(() => { + originalSettings = useAppStore.getState().settings + useAppStore.setState({ + settings: { + customTuiAgents: [derivedAgent(CUSTOM_DROID_ID, 'droid'), derivedAgent(CUSTOM_PI_ID, 'pi')], + deletedCustomTuiAgents: [] + } as unknown as AppState['settings'] + }) + }) + + afterEach(() => { + useAppStore.setState({ settings: originalSettings }) + }) + + function trustedState( + agent: CustomTuiAgent['id'] + ): Parameters[0] { + return { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent, routingTrusted: true, shellForeground: false } + } + } + } + + it('a trusted custom agent inherits its base CSI-u capability without throwing', () => { + expect(() => + hasCtrlEnterCsiUAuthorityForPane(trustedState(CUSTOM_DROID_ID), PANE_KEY) + ).not.toThrow() + expect(hasCtrlEnterCsiUAuthorityForPane(trustedState(CUSTOM_DROID_ID), PANE_KEY)).toBe(true) + expect(hasCtrlEnterCsiUAuthorityForPane(trustedState(CUSTOM_PI_ID), PANE_KEY)).toBe(false) + }) + + it('matches a custom foreground against its base committed title', () => { + const state = { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent: CUSTOM_DROID_ID, shellForeground: false } + } + } + expect(hasCtrlEnterCsiUAuthorityForPane(state, PANE_KEY, '⠋ Droid')).toBe(true) + expect(hasCtrlEnterCsiUAuthorityForPane(state, PANE_KEY, 'C:\\work\\grok-project')).toBe(false) + }) + + it('denies CSI-u for a custom id the catalog cannot resolve', () => { + useAppStore.setState({ + settings: { + customTuiAgents: [], + deletedCustomTuiAgents: [] + } as unknown as AppState['settings'] + }) + expect(() => + hasCtrlEnterCsiUAuthorityForPane(trustedState(CUSTOM_DROID_ID), PANE_KEY) + ).not.toThrow() + expect(hasCtrlEnterCsiUAuthorityForPane(trustedState(CUSTOM_DROID_ID), PANE_KEY)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts index 457a1f51beb..11ffa63b1ca 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts @@ -1,5 +1,6 @@ -import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { isBuiltInTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { resolveCommittedTitleAgentType } from '../../lib/pane-agent-evidence' +import { resolvePaneOwnerBaseAgent } from '../../lib/agent-base-identity' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' type CtrlEnterPaneState = { @@ -7,7 +8,10 @@ type CtrlEnterPaneState = { } function agentAcceptsCtrlEnterCsiU(agent: PaneForegroundAgentEntry['agent']): boolean { - return agent !== null && TUI_AGENT_CONFIG[agent].ctrlEnterEncoding === 'csi-u' + // Why base-resolved and guarded: TUI_AGENT_CONFIG is keyed by built-in ids only; + // a custom agent inherits its base's encoding, an unresolvable id gets plain CR. + const baseAgent = resolvePaneOwnerBaseAgent(agent ?? undefined) + return isBuiltInTuiAgent(baseAgent) && TUI_AGENT_CONFIG[baseAgent].ctrlEnterEncoding === 'csi-u' } /** Resolves pane-scoped authority for query-only CSI-u consumers such as Droid and Grok. */ @@ -24,7 +28,9 @@ export function hasCtrlEnterCsiUAuthorityForPane( return agentAcceptsCtrlEnterCsiU(foreground.agent) } const titleAgent = terminalTitle ? resolveCommittedTitleAgentType(terminalTitle) : null - if (foreground?.agent != null && foreground.agent !== titleAgent) { + // Why base-resolved: the veto compares harness identity, and a custom id compares + // raw-false against its own base's committed title, silently dropping CSI-u. + if (foreground?.agent != null && resolvePaneOwnerBaseAgent(foreground.agent) !== titleAgent) { return false } return agentAcceptsCtrlEnterCsiU(titleAgent) diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx index 29ba3bd06a8..3d6a66ce857 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx @@ -4,7 +4,6 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' -import { beforeEach } from 'vitest' import { TerminalQuickCommandDialog } from './TerminalQuickCommandDialog' const useLocalAgentCatalogMock = vi.fn((_options?: { enabled?: boolean }) => ({ diff --git a/src/renderer/src/lib/agent-catalog-custom-display.test.tsx b/src/renderer/src/lib/agent-catalog-custom-display.test.tsx index 860b0cb498d..e6344dd18d1 100644 --- a/src/renderer/src/lib/agent-catalog-custom-display.test.tsx +++ b/src/renderer/src/lib/agent-catalog-custom-display.test.tsx @@ -1,8 +1,7 @@ // @vitest-environment happy-dom -import { render, screen } from '@testing-library/react' +import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { cleanup } from '@testing-library/react' import type { CustomTuiAgentId } from '../../../shared/types' import { AgentIcon, getAgentLabel } from './agent-catalog' import { registerAgentCatalogSettingsSource } from './agent-catalog-settings-source' diff --git a/src/renderer/src/lib/detected-agent-availability.ts b/src/renderer/src/lib/detected-agent-availability.ts index ff3cd44e42c..eddce6a1b4d 100644 --- a/src/renderer/src/lib/detected-agent-availability.ts +++ b/src/renderer/src/lib/detected-agent-availability.ts @@ -7,7 +7,7 @@ import type { TuiAgent } from '../../../shared/types' * * Host detection probes built-in harness binaries only, so a custom id is never * a member of that list: a baseline-stock custom stands on its base harness's - * detection, and one carrying its own executable is host-preflighted at launch + * detection, and one carrying its own executable is launch-reported (only the launch itself checks it) * and never client-gated (oracle 35). Without this, every custom assignment * reads as "not available on this host" and its surface refuses to launch. */ export function isDetectedAgentAvailable( diff --git a/src/renderer/src/lib/source-control-agent-action-plan.test.ts b/src/renderer/src/lib/source-control-agent-action-plan.test.ts index fec39083bad..a2e32599396 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.test.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.test.ts @@ -37,22 +37,22 @@ describe('resolveSourceControlAgentAvailability', () => { }) }) - it('marks a built-in with a configured executable override host-preflight', () => { + it('marks a built-in with a configured executable override launch-reported', () => { expect( resolveSourceControlAgentAvailability( 'claude', settings({ agentCmdOverrides: { claude: '/opt/claude' } }) ) - ).toEqual({ baseAgent: 'claude', availabilityClass: 'host-preflight' }) + ).toEqual({ baseAgent: 'claude', availabilityClass: 'launch-reported' }) }) - it('marks a built-in with agent env host-preflight', () => { + it('marks a built-in with agent env launch-reported', () => { expect( resolveSourceControlAgentAvailability( 'claude', settings({ agentDefaultEnv: { claude: { API_KEY: 'x' } } }) ) - ).toEqual({ baseAgent: 'claude', availabilityClass: 'host-preflight' }) + ).toEqual({ baseAgent: 'claude', availabilityClass: 'launch-reported' }) }) it('resolves a plain custom agent to its base, baseline-detection', () => { @@ -64,22 +64,22 @@ describe('resolveSourceControlAgentAvailability', () => { ).toEqual({ baseAgent: 'codex', availabilityClass: 'baseline-detection' }) }) - it('marks a custom agent with a command override host-preflight', () => { + it('marks a custom agent with a command override launch-reported', () => { expect( resolveSourceControlAgentAvailability( CUSTOM_ID, settings({ customTuiAgents: [customAgent({ commandOverride: 'my-codex' })] }) ) - ).toEqual({ baseAgent: 'codex', availabilityClass: 'host-preflight' }) + ).toEqual({ baseAgent: 'codex', availabilityClass: 'launch-reported' }) }) - it('marks a custom agent with env host-preflight', () => { + it('marks a custom agent with env launch-reported', () => { expect( resolveSourceControlAgentAvailability( CUSTOM_ID, settings({ customTuiAgents: [customAgent({ env: { TOKEN: 'y' } })] }) ) - ).toEqual({ baseAgent: 'codex', availabilityClass: 'host-preflight' }) + ).toEqual({ baseAgent: 'codex', availabilityClass: 'launch-reported' }) }) it('returns a null base for an unknown custom id', () => { @@ -131,11 +131,11 @@ describe('planSourceControlAgentActionLaunch', () => { ).toEqual({ ok: false, error: 'The selected agent was not detected on this workspace host.' }) }) - it('never blocks a host-preflight agent whose base is not detected', () => { + it('never blocks a launch-reported agent whose base is not detected', () => { const result = planSourceControlAgentActionLaunch({ agent: CUSTOM_ID, baseAgent: 'codex', - availabilityClass: 'host-preflight', + availabilityClass: 'launch-reported', commandInput: 'Fix checks', promptDelivery: 'submit-after-ready', detectedAgents: [] diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index 04b6f86600a..78712c35fa4 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -6,9 +6,10 @@ import { translate } from '@/i18n/i18n' // Preview classes mirror the host `availabilityCheck` projection (plan §830/§972): // a stock-prefix launch is gated on baseline detection, while a configured -// executable or agent env is host-preflight and must never be blocked here — -// baseline stock detection cannot evaluate it, and the real launch reports the outcome. -export type SourceControlAgentAvailabilityClass = 'baseline-detection' | 'host-preflight' +// executable or agent env is launch-reported and must never be blocked here — +// baseline stock detection cannot evaluate it, no preflight runs anywhere, and +// only the real launch on the execution host reports the outcome. +export type SourceControlAgentAvailabilityClass = 'baseline-detection' | 'launch-reported' export type SourceControlAgentAvailability = { /** Proven built-in base of the selected id; null for an unknown/unresolvable id. */ @@ -25,7 +26,7 @@ type SourceControlAgentAvailabilitySettings = Partial< /** Resolve the selected agent's proven base and availability class from the local * catalog view. Custom ids resolve their base through the catalog (never id syntax), - * and a configured executable or agent env marks the row host-preflight so a + * and a configured executable or agent env marks the row launch-reported so a * base-not-detected host cannot falsely block it. Zero host projection field added. */ export function resolveSourceControlAgentAvailability( agent: TuiAgent | null | undefined, @@ -46,7 +47,7 @@ export function resolveSourceControlAgentAvailability( ) return { baseAgent, - availabilityClass: usesHostPreflight ? 'host-preflight' : 'baseline-detection' + availabilityClass: usesHostPreflight ? 'launch-reported' : 'baseline-detection' } } const override = settings?.agentCmdOverrides?.[baseAgent] @@ -54,7 +55,7 @@ export function resolveSourceControlAgentAvailability( const usesHostPreflight = Boolean(override || (env && Object.keys(env).length > 0)) return { baseAgent, - availabilityClass: usesHostPreflight ? 'host-preflight' : 'baseline-detection' + availabilityClass: usesHostPreflight ? 'launch-reported' : 'baseline-detection' } } diff --git a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts index b1480259599..3da324359a5 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts @@ -62,7 +62,9 @@ function productionSources(): { path: string; source: string }[] { walk(path) continue } - if (!/\.(ts|tsx)$/.test(entry) || /\.test\.|\.d\.ts$/.test(entry)) { + // `-test-fixtures.ts` carries no `.test.` segment, so name it explicitly: mock + // return values there are scaffolding, not production settle sites. + if (!/\.(ts|tsx)$/.test(entry) || /\.test\.|\.d\.ts$|-test-fixtures?\.tsx?$/.test(entry)) { continue } sources.push({ diff --git a/src/renderer/src/runtime/web-runtime-session-tab-actions.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-actions.test.ts index d4dfbb52b94..0b1ccbb3f42 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-actions.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-actions.test.ts @@ -25,7 +25,9 @@ const mocks = vi.hoisted(() => ({ moveUnifiedTabToGroup: vi.fn(), setRemoteBrowserPageHandle: vi.fn(), focusBrowserTabInWorktree: vi.fn(), - applyFreshWebSessionTabsSnapshot: vi.fn(), + applyWebSessionTabsSnapshot: vi.fn(), + decideWebSessionTabsSnapshot: vi.fn(), + settleWebSessionTabsMirror: vi.fn(), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -44,9 +46,13 @@ vi.mock('../store', () => ({ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, - applyFreshWebSessionTabsSnapshot: mocks.applyFreshWebSessionTabsSnapshot, - applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => - mocks.setState(buildPatch), + applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, + decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { + mocks.setState(buildPatch) + // The production caller invokes the returned settle receipt. + return mocks.settleWebSessionTabsMirror + }, resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab })) @@ -73,7 +79,8 @@ describe('moveWebRuntimeSessionTab', () => { }, setActiveWorktree: mocks.setActiveWorktree }) - mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) + mocks.decideWebSessionTabsSnapshot.mockReturnValue({ apply: true, settlesHostMirror: true }) + mocks.applyWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) }) afterEach(() => { @@ -120,7 +127,8 @@ describe('moveWebRuntimeSessionTab', () => { timeoutMs: 15_000 }) expect(runtimeCall).toHaveBeenCalledTimes(1) - expect(mocks.applyFreshWebSessionTabsSnapshot).not.toHaveBeenCalled() + expect(mocks.decideWebSessionTabsSnapshot).not.toHaveBeenCalled() + expect(mocks.applyWebSessionTabsSnapshot).not.toHaveBeenCalled() }) it('maps mirrored local browser unified ids back to host session tab ids', async () => { @@ -279,7 +287,8 @@ describe('web runtime session tab actions', () => { mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => updater({ state: 'before', activeWorktreeId: WORKTREE_ID }) ) - mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) + mocks.decideWebSessionTabsSnapshot.mockReturnValue({ apply: true, settlesHostMirror: true }) + mocks.applyWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( (_state, args: { tabId: string }) => args.tabId === 'local-browser-unified' ? 'host-browser-unified' : null @@ -363,7 +372,15 @@ describe('web runtime session tab actions', () => { }, timeoutMs: 15_000 }) - expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalled() + // Why: the close's eager list must flow through decide → apply so host + // activation state mirrors locally only once this frame's decision allows it. + expect(mocks.decideWebSessionTabsSnapshot).toHaveBeenCalledWith(makeSnapshot(), ENVIRONMENT_ID) + expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith( + { state: 'before', activeWorktreeId: WORKTREE_ID }, + makeSnapshot(), + ENVIRONMENT_ID + ) + expect(mocks.settleWebSessionTabsMirror).toHaveBeenCalled() }) it('supersedes browser focus intent when a terminal is activated next', async () => { @@ -558,9 +575,41 @@ describe('web runtime session tab actions', () => { ENVIRONMENT_ID, WORKTREE_ID ) + // Why: freshness tracking must be released before the authoritative re-list + // is applied, or the republished snapshot would be rejected as stale. expect(mocks.acceptReplayedWebSessionTabsSnapshot.mock.invocationCallOrder[0]).toBeLessThan( - mocks.applyFreshWebSessionTabsSnapshot.mock.invocationCallOrder[0]! + mocks.applyWebSessionTabsSnapshot.mock.invocationCallOrder[0]! ) + expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith( + { state: 'before', activeWorktreeId: WORKTREE_ID }, + authoritative, + ENVIRONMENT_ID + ) + }) + + it('discards a snapshot the frame decision rejects without patching local tabs', async () => { + mocks.decideWebSessionTabsSnapshot.mockReturnValue({ apply: false, settlesHostMirror: true }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ id: 'close', ok: true, result: {} }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'user' + }) + ).resolves.toBe(true) + + expect(mocks.decideWebSessionTabsSnapshot).toHaveBeenCalledWith(makeSnapshot(), ENVIRONMENT_ID) + // Why: an outranked answer never rewrites local tabs, but the mirror still + // settles because an equal-or-newer accepted view backs the rejection. + expect(mocks.applyWebSessionTabsSnapshot).not.toHaveBeenCalled() + expect(mocks.settleWebSessionTabsMirror).toHaveBeenCalled() }) it('keeps the close intent when a refused lifecycle close was not republished', async () => { diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-agent-launch.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-agent-launch.test.ts index fc4e893789b..5aaefe55cdb 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-agent-launch.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-agent-launch.test.ts @@ -26,7 +26,8 @@ const mocks = vi.hoisted(() => ({ moveUnifiedTabToGroup: vi.fn(), setRemoteBrowserPageHandle: vi.fn(), focusBrowserTabInWorktree: vi.fn(), - applyFreshWebSessionTabsSnapshot: vi.fn(), + applyWebSessionTabsSnapshot: vi.fn(), + decideWebSessionTabsSnapshot: vi.fn(), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -45,9 +46,13 @@ vi.mock('../store', () => ({ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, - applyFreshWebSessionTabsSnapshot: mocks.applyFreshWebSessionTabsSnapshot, - applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => - mocks.setState(buildPatch), + applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, + decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { + mocks.setState(buildPatch) + // The production caller invokes the returned settle receipt. + return () => {} + }, resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab })) diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-create.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-create.test.ts index 108bb8b6cd1..d93aac9e0a0 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-create.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-create.test.ts @@ -24,7 +24,9 @@ const mocks = vi.hoisted(() => ({ moveUnifiedTabToGroup: vi.fn(), setRemoteBrowserPageHandle: vi.fn(), focusBrowserTabInWorktree: vi.fn(), - applyFreshWebSessionTabsSnapshot: vi.fn(), + applyWebSessionTabsSnapshot: vi.fn(), + decideWebSessionTabsSnapshot: vi.fn(), + settleWebSessionTabsMirror: vi.fn(), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -43,9 +45,13 @@ vi.mock('../store', () => ({ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, - applyFreshWebSessionTabsSnapshot: mocks.applyFreshWebSessionTabsSnapshot, - applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => - mocks.setState(buildPatch), + applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, + decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { + mocks.setState(buildPatch) + // The production caller invokes the returned settle receipt. + return mocks.settleWebSessionTabsMirror + }, resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab })) @@ -307,11 +313,15 @@ describe('createWebRuntimeSessionTerminal', () => { }, timeoutMs: 15_000 }) - expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalledWith( + // Why: activation mirroring is host-authoritative — the post-create list must + // pass through the frame's own decision before it may patch the local store. + expect(mocks.decideWebSessionTabsSnapshot).toHaveBeenCalledWith(snapshot, ENVIRONMENT_ID) + expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith( { state: 'before', activeWorktreeId: WORKTREE_ID }, snapshot, ENVIRONMENT_ID ) + expect(mocks.settleWebSessionTabsMirror).toHaveBeenCalled() }) it('keeps exact legacy ordering when structured creation cannot express afterTabId', async () => { diff --git a/src/renderer/src/runtime/web-runtime-session-test-fixtures.ts b/src/renderer/src/runtime/web-runtime-session-test-fixtures.ts index 34784f9daf5..3fe1802678c 100644 --- a/src/renderer/src/runtime/web-runtime-session-test-fixtures.ts +++ b/src/renderer/src/runtime/web-runtime-session-test-fixtures.ts @@ -13,7 +13,8 @@ export type WebRuntimeSessionTestMocks = { moveUnifiedTabToGroup: Mock setRemoteBrowserPageHandle: Mock focusBrowserTabInWorktree: Mock - applyFreshWebSessionTabsSnapshot: Mock + applyWebSessionTabsSnapshot: Mock + decideWebSessionTabsSnapshot: Mock acceptReplayedWebSessionTabsSnapshot: Mock resolveHostSessionTabIdForWebSessionTab: Mock trackTerminalPaneSplit: Mock @@ -83,7 +84,8 @@ export function primeTerminalSessionState(mocks: WebRuntimeSessionTestMocks): vo activeWorktreeId: WORKTREE_ID }) }) - mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) + mocks.decideWebSessionTabsSnapshot.mockReturnValue({ apply: true, settlesHostMirror: true }) + mocks.applyWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue(null) mocks.deliverLaunchPromptToAgentTab.mockResolvedValue(true) } diff --git a/src/renderer/src/web/web-agent-catalog-sync.test.ts b/src/renderer/src/web/web-agent-catalog-sync.test.ts index aeb0af980a0..629c6aa3900 100644 --- a/src/renderer/src/web/web-agent-catalog-sync.test.ts +++ b/src/renderer/src/web/web-agent-catalog-sync.test.ts @@ -20,7 +20,7 @@ function hostSnapshot(overrides: Partial = {}): AgentCatal syncEnv: true, status: 'ready', envState: 'available', - availabilityCheck: 'host-preflight' + availabilityCheck: 'launch-reported' } ], deletedCustomAgents: [], diff --git a/src/renderer/src/web/web-agent-catalog-sync.ts b/src/renderer/src/web/web-agent-catalog-sync.ts index 2d96fcb7022..8fdd71e6cc4 100644 --- a/src/renderer/src/web/web-agent-catalog-sync.ts +++ b/src/renderer/src/web/web-agent-catalog-sync.ts @@ -46,11 +46,12 @@ function readyRow(agent: Extract): Lo }, // Env never crosses the wire, so its size is unknowable here; no UI reads it. envSummary: { entryCount: 0, bytes: 0 }, - // `host-preflight` means the host, not stock base detection, vouches for the - // row — keep it out of `baseline-stock` so clients don't gate it on detection. + // `launch-reported` means stock base detection cannot vouch for the row and + // only the launch on the execution host establishes availability — keep it + // out of `baseline-stock` so clients don't gate it on detection. availabilityReason: agent.commandOverride ? 'configured-executable' - : agent.availabilityCheck === 'host-preflight' + : agent.availabilityCheck === 'launch-reported' ? 'custom-path' : 'baseline-stock' } diff --git a/src/shared/agent-catalog-snapshot.ts b/src/shared/agent-catalog-snapshot.ts index e87418766f2..8a0b449ec16 100644 --- a/src/shared/agent-catalog-snapshot.ts +++ b/src/shared/agent-catalog-snapshot.ts @@ -42,7 +42,13 @@ export type SyncedCustomTuiAgent = // Describes host launch capability; keys and values are never projected. envState: 'none' | 'available' | 'withheld' // Conservative remote UX hint; never identifies PATH or another env key. - availabilityCheck: 'baseline-detection' | 'host-preflight' + // 'launch-reported' means baseline stock detection CANNOT vouch for this + // row (configured executable or host-applicable env) and NO preflight runs + // anywhere: availability is only ever established by an actual launch on + // the execution host, which reports its own failure. Clients must not gate + // such rows on baseline detection — and must not treat the value as proof + // the executable exists. + availabilityCheck: 'baseline-detection' | 'launch-reported' }) | { id: CustomTuiAgentId