From f03dc12b0d9605eb44f170bc3e6d250807ce59ea Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:45:29 -0700 Subject: [PATCH] feat(worktree): prepare agent shells in retained composer drafts --- config/reliability-gates.jsonc | 24 +- .../worktree-create-retained-drafts.md | 35 ++- .../daemon/daemon-create-or-attach-result.ts | 17 ++ .../daemon-deferred-startup-protocol.test.ts | 25 ++ src/main/daemon/daemon-pty-spawn-result.ts | 4 +- src/main/daemon/daemon-terminal-admission.ts | 3 + src/main/daemon/session-deferred-startup.ts | 11 +- src/main/daemon/session-startup-input.ts | 5 + src/main/daemon/session.ts | 5 + .../daemon/terminal-host-create-contract.ts | 2 + src/main/ipc/pty/ipc/spawn-commit.ts | 11 +- src/main/ipc/pty/provider/ownership-state.ts | 3 +- .../pty/provider/pty-ownership-registry.ts | 65 ++++++ src/main/ipc/pty/provider/state-cleanup.ts | 1 + src/main/ipc/pty/runtime/deferred-startup.ts | 15 +- src/main/ipc/pty/runtime/spawn-commit.ts | 30 +-- .../pty/runtime/spawn-startup-effects.test.ts | 219 ++++++++++++++++++ .../ipc/pty/runtime/spawn-startup-effects.ts | 53 +++++ src/main/ipc/worktree-remote.ts | 23 +- .../worktrees-deferred-startup-create.test.ts | 165 +++++++++++++ src/main/ipc/worktrees.ts | 4 + .../register-worktree-create-handlers.ts | 8 + ...worktree-deferred-startup-handlers.test.ts | 83 +++++++ ...ster-worktree-deferred-startup-handlers.ts | 57 +++++ src/main/providers/pty-spawn-result.ts | 2 + src/preload/api/worktree-api.ts | 4 + src/preload/api/worktrees-bridge.ts | 3 + src/renderer/src/i18n/locales/en.json | 7 + .../src/lib/composer-deferred-startup.ts | 37 +++ .../src/lib/create-requested-worktree.test.ts | 97 +++++++- .../src/lib/create-requested-worktree.ts | 9 +- .../src/lib/pending-worktree-creation.ts | 3 + .../src/lib/worktree-creation-completion.ts | 9 +- ...worktree-creation-deferred-startup.test.ts | 211 +++++++++++++++++ .../src/lib/worktree-creation-flow-execute.ts | 25 +- .../lib/worktree-deferred-startup-release.ts | 59 +++++ .../src/store/slices/worktree-helpers.ts | 1 + .../worktrees/create/create-worktree.ts | 7 + src/shared/deferred-startup-release.ts | 1 + src/shared/worktree/create-types.ts | 1 + src/shared/worktree/launch-types.ts | 8 + 41 files changed, 1297 insertions(+), 55 deletions(-) create mode 100644 src/main/ipc/pty/provider/pty-ownership-registry.ts create mode 100644 src/main/ipc/pty/runtime/spawn-startup-effects.test.ts create mode 100644 src/main/ipc/pty/runtime/spawn-startup-effects.ts create mode 100644 src/main/ipc/worktrees-deferred-startup-create.test.ts create mode 100644 src/main/ipc/worktrees/create/register-worktree-deferred-startup-handlers.test.ts create mode 100644 src/main/ipc/worktrees/create/register-worktree-deferred-startup-handlers.ts create mode 100644 src/renderer/src/lib/composer-deferred-startup.ts create mode 100644 src/renderer/src/lib/worktree-creation-deferred-startup.test.ts create mode 100644 src/renderer/src/lib/worktree-deferred-startup-release.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0d280bf870e..a45afa95c0b 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -22,17 +22,22 @@ "providers": ["daemon", "local", "wsl", "ssh", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["daemon"], - "coverageNotes": "Actual Session and TerminalHost contracts run on macOS. Windows and WSL argv branches are mocked. Daemon v37 preparation and release run over authenticated control sockets; older daemon preparation and unsupported direct/SSH providers fail closed. Runtime background terminal creation now forwards deferral with fresh-owner admission; the multi-daemon router releases on the exact retained owner. An ad hoc native macOS zsh PTY check verified profile-before-release and one stand-in command execution. No composer caller enables this yet; real-agent readiness, rendered latency, and other provider release paths remain unverified.", + "coverageNotes": "Actual Session and TerminalHost contracts run on macOS. Windows and WSL argv branches are mocked. Daemon v37 preparation and release run over authenticated control sockets; older daemon preparation and unsupported direct/SSH providers fail closed. Runtime background terminal creation now forwards deferral with fresh-owner admission; the multi-daemon router releases on the exact retained owner. An ad hoc native macOS zsh PTY check verified profile-before-release and one stand-in command execution. Native local composer now prepares held agent shells. Rendered Codex trials measured 582 ms warmed and 1499 ms quick-click prompt/model availability, with MCP still initializing and test-only update checking disabled. A canceled draft remained usable as a shell and manual input retired its command. Other provider release paths and consistent full readiness remain unverified.", "motivatingLinks": ["docs/reference/worktree-create-retained-drafts.md"], "invariant": "Shell readiness cannot authorize deferred agent execution. Only the same operation and terminal incarnation may release once. Manual input retires an unreleased command; exit or termination prevents a queued command from reaching the subprocess. Ambiguous writes are never replayed.", "oracle": "Record actual Session subprocess writes while emitting readiness, advancing its timeout, reattaching clients, injecting query replies and manual input, terminating, and throwing after writing bytes. Require no command before release, at most one after it, and no write from wrong identity or after teardown.", "commands": [ + "pnpm test src/renderer/src/lib/create-requested-worktree.test.ts src/renderer/src/lib/worktree-creation-deferred-startup.test.ts src/renderer/src/lib/worktree-creation-flow.test.ts src/main/ipc/worktrees-deferred-startup-create.test.ts src/main/ipc/worktrees/create/register-worktree-deferred-startup-handlers.test.ts", "pnpm test src/main/runtime/orca-runtime-deferred-startup-create.test.ts src/main/runtime/cli-terminal-create-host-session-binding.test.ts src/main/runtime/orca-runtime-terminal-cwd.test.ts src/main/ipc/pty/runtime/deferred-startup.test.ts src/main/daemon/daemon-deferred-startup-router.test.ts", "pnpm test src/main/daemon/daemon-deferred-startup-router.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/ipc/pty/runtime/deferred-startup.test.ts src/main/runtime/orca-runtime-deferred-startup-create.test.ts", "pnpm test src/main/daemon/daemon-deferred-startup-protocol.test.ts src/main/daemon/daemon-protocol-version.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/main/providers/ssh-pty-deferred-startup.test.ts", "pnpm test src/main/daemon/session-deferred-startup.test.ts src/main/daemon/terminal-host-startup.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-agent-session.test.ts src/main/daemon/pty-subprocess-windows-shell-launch.test.ts src/main/daemon/pty-subprocess-wsl-launch.test.ts src/main/daemon/pty-subprocess-managed-agent-env.test.ts" ], "testFiles": [ + "src/renderer/src/lib/create-requested-worktree.test.ts", + "src/renderer/src/lib/worktree-creation-deferred-startup.test.ts", + "src/main/ipc/worktrees-deferred-startup-create.test.ts", + "src/main/ipc/worktrees/create/register-worktree-deferred-startup-handlers.test.ts", "src/main/daemon/daemon-deferred-startup-router.test.ts", "src/main/ipc/pty/runtime/deferred-startup.test.ts", "src/main/runtime/orca-runtime-deferred-startup-create.test.ts", @@ -56,6 +61,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-09-05", + "runner": "local", + "platform": "macos", + "command": "pnpm test src/renderer/src/lib/create-requested-worktree.test.ts src/renderer/src/lib/worktree-creation-deferred-startup.test.ts src/renderer/src/lib/worktree-creation-flow.test.ts src/main/ipc/worktrees-deferred-startup-create.test.ts src/main/ipc/worktrees/create/register-worktree-deferred-startup-handlers.test.ts", + "result": "passed", + "durationSeconds": 2.03, + "summary": "58 tests across five suites passed. Covers capability fallback, exact receipt release and retry, cancellation, trust ordering, missing identity, and context delivery without relaunch. Rendered native Codex and cancellation evidence is recorded separately in the retained-drafts reference." + }, { "date": "2026-09-05", "runner": "local", @@ -94,20 +108,20 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "CPR/DA integration tests failed before reply exclusion and passed after it. No complete intentional-break matrix or real deferred composer journey yet." + "evidence": "CPR/DA integration tests failed before reply exclusion and passed after it. Rendered native composer Create and cancellation were exercised; no complete intentional-break matrix yet." }, "performanceBudget": { "required": true, "evidence": "One bounded startup record per Session, no polling or subprocess added. Existing readiness queue carries one accepted callback. Input classification runs only while that Session has an unreleased command." }, "promotionCriteria": [ - "Implement capability-negotiated owner release and the rendered composer integration.", + "Complete supported-topology composer integration and consistent selected-agent readiness.", "Verify live local, daemon, Windows/WSL and SSH release and crash recovery.", "Meet manifest CI and soak policy." ], "knownGaps": [ - "Daemon capability negotiation and release are implemented; no composer caller or remote release yet.", - "No live shell-profile or selected-agent readiness timing for deferred launch.", + "Native composer deferral is implemented; remote release and crash/restart proof remain outstanding.", + "Native prompt/model timings are single samples, not full tool readiness or a near-instant guarantee.", "Direct local provider and SSH/remote release parity remain unimplemented." ], "demotionRule": "Keep experimental until topology, fault-injection and rendered proof exist; demote on premature or repeated launch, stale identity acceptance, or unexplained flakes." diff --git a/docs/reference/worktree-create-retained-drafts.md b/docs/reference/worktree-create-retained-drafts.md index aa822fb2a78..68a99db471f 100644 --- a/docs/reference/worktree-create-retained-drafts.md +++ b/docs/reference/worktree-create-retained-drafts.md @@ -12,9 +12,11 @@ on Create. Cancellation retains it in the normal workspace catalog. Automatic preparation currently requires the direct desktop API and the `worktree.background-startup.v1` capability. Runtime-environment and paired-web clients use ordinary Create until equivalent ownership/adoption is implemented. -Agent launches, folder targets, VM recipes, unresolved PR/issue sources and missing -hook approval are not automatically prepared yet. These remain implementation -work, not exclusions from the near-instant creation objective. +Native local agent drafts now prepare their shell when the daemon supports +owner-fenced deferred startup. The original agent command is held until Create; +unsupported providers retain checkout-only preparation. Folder targets, VM recipes, +unresolved PR/issue sources and missing hook approval are not automatically prepared +yet. These remain implementation work, not exclusions from the near-instant objective. The optional `startup.activate: false` field leaves selection unchanged. Older callers omit it and retain ordinary activation. Preparing clients must verify the @@ -86,5 +88,28 @@ existing prepared checkout. These are single samples, not a performance guarante A generated-name canceled draft remained visible in the rendered sidebar after renderer reload. Application-process crash/restart, dirty/ignored profile output, -and all remote topology cases still need verification. Warm-shell agent promotion -is not yet implemented; terminal reattachment deliberately drops startup commands. +and all remote topology cases still need verification. + +## Deferred agent-shell composer measurements (development build, macOS) + +The native local composer now prepares the original agent shell/environment and +holds its command in the daemon until Create. Release requires the same workspace, +PTY incarnation and operation; retry cannot create a replacement or type a fallback +command into an uncertain terminal. Manual use retires the held command. +Preparation does not publish agent-start telemetry or mark Claude as running. +Daemon reattachment includes optional deferred-startup state, so a new main process +can distinguish an unreleased shell from an agent. Telemetry acknowledgement state +is not durable across main-process crashes. Claude release rechecks account switches. + +Two real Codex trials each created one workspace. With 2.62 seconds between opening +and Create, selection took 156.9 ms and the prompt with a resolved model appeared +after 582.1 ms. Clicking after 333.7 ms took 497.3 ms to selection and 1498.6 ms to +the prompt/model. MCP servers were still initializing. The isolated test settings +disabled Codex's update check; product defaults are unchanged. These single samples +do not establish consistent near-instant agent or tool readiness. + +A canceled draft was found through workspace search and opened as a working shell. +Running a harmless printf command rendered its output; a subsequent release of the +original held command returned `retired`. No second workspace or agent was started. +Final readiness, crash/restart, supported-topology and adverse-path validation are +still outstanding before publishing the combined ad hoc release. diff --git a/src/main/daemon/daemon-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts index 92a7e451a10..c5b9c431b19 100644 --- a/src/main/daemon/daemon-create-or-attach-result.ts +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -2,8 +2,10 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { ShellReadyState, TerminalSnapshot } from './types' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { DeferredStartupStatus } from '../../shared/deferred-startup-release' export type DaemonCreateOrAttachResult = { + deferredStartupStatus?: DeferredStartupStatus isNew: boolean snapshot: TerminalSnapshot | null pid: number | null @@ -22,16 +24,31 @@ export type DaemonCreateOrAttachResult = { cwdReadableByDaemon?: boolean } +export function getDaemonStartupResultMetadata(result: DaemonCreateOrAttachResult): { + incarnationId?: PtyIncarnationId + deferredStartupStatus?: DeferredStartupStatus +} { + return { + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.deferredStartupStatus ? { deferredStartupStatus: result.deferredStartupStatus } : {}) + } +} + export function getDaemonSessionResultMetadata(session: { launchAgent: TuiAgent | null historySeeded: boolean | undefined wslDistro: string | null + deferredStartupStatus?: DeferredStartupStatus }): { + deferredStartupStatus?: DeferredStartupStatus launchAgent?: TuiAgent historySeeded?: boolean wslDistro: string | null } { return { + ...(session.deferredStartupStatus + ? { deferredStartupStatus: session.deferredStartupStatus } + : {}), ...(session.launchAgent ? { launchAgent: session.launchAgent } : {}), ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), // Why: null authoritatively identifies a native session; omission is diff --git a/src/main/daemon/daemon-deferred-startup-protocol.test.ts b/src/main/daemon/daemon-deferred-startup-protocol.test.ts index e65929e0b42..18337b71933 100644 --- a/src/main/daemon/daemon-deferred-startup-protocol.test.ts +++ b/src/main/daemon/daemon-deferred-startup-protocol.test.ts @@ -102,6 +102,31 @@ describe('deferred startup daemon control protocol', () => { await vi.waitFor(() => expect(subprocess.write).toHaveBeenCalledOnce()) }) + it.each(['pending', 'retired'] as const)( + 'reattaches with authoritative %s startup after client restart', + async (status) => { + const { adapter, socketPath, tokenPath } = await start() + const original = await prepare(adapter) + expect(original.deferredStartupStatus).toBe('pending') + subprocess._simulateData(ready) + if (status === 'retired') { + adapter.write(sessionId, 'echo manual\r') + await vi.waitFor(() => expect(subprocess.write).toHaveBeenCalledOnce()) + } + adapter.dispose() + const reconnected = new DaemonPtyAdapter({ socketPath, tokenPath }) + adapters.push(reconnected) + const result = await reconnected.spawn({ sessionId, cols: 80, rows: 24, command }) + expect(result).toMatchObject({ + isReattach: true, + incarnationId: original.incarnationId, + deferredStartupStatus: status + }) + expect(spawn).toHaveBeenCalledOnce() + expect(subprocess.write).toHaveBeenCalledTimes(status === 'retired' ? 1 : 0) + } + ) + it('rejects old-owner preparation before any spawn or release request', async () => { const { adapter } = await start(DEFERRED_STARTUP_DAEMON_PROTOCOL_VERSION - 1) expect(adapter.supportsDeferredStartupCommands()).toBe(false) diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index 5e799230fa1..5e99067e5b5 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -1,3 +1,4 @@ +import { getDaemonStartupResultMetadata } from './daemon-create-or-attach-result' import { isAgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' import { retireUnexpectedAttachOnlySpawn } from './daemon-attach-only-retirement' @@ -80,8 +81,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { } const claimResult = (): Pick | Record => result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} - const incarnationResult = (): Pick | Record => - result.incarnationId ? { incarnationId: result.incarnationId } : {} + const incarnationResult = () => getDaemonStartupResultMetadata(result) let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. wslDistro = providerWslDistro ?? undefined diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index 3c0777bfc4a..46e61c4e4fb 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -170,6 +170,9 @@ export class DaemonTerminalAdmission { pid: result.pid, shellState: result.shellState, incarnationId: result.incarnationId, + ...(result.deferredStartupStatus + ? { deferredStartupStatus: result.deferredStartupStatus } + : {}), ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), wslDistro: result.wslDistro, ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), diff --git a/src/main/daemon/session-deferred-startup.ts b/src/main/daemon/session-deferred-startup.ts index 4e665bb02fa..1ebdd2f85e9 100644 --- a/src/main/daemon/session-deferred-startup.ts +++ b/src/main/daemon/session-deferred-startup.ts @@ -1,4 +1,7 @@ -import type { StartupCommandReleaseResult } from '../../shared/deferred-startup-release' +import type { + DeferredStartupStatus, + StartupCommandReleaseResult +} from '../../shared/deferred-startup-release' export type { StartupCommandReleaseResult } from '../../shared/deferred-startup-release' export type DeferredSessionStartup = { @@ -8,7 +11,7 @@ export type DeferredSessionStartup = { /** Keeps Create authorization separate from the shell readiness timeout. */ export class SessionDeferredStartup { - private state: 'pending' | 'accepted' | 'unverifiable' | 'retired' = 'pending' + private state: DeferredStartupStatus = 'pending' private submission: string | null private readonly operationId: string @@ -21,6 +24,10 @@ export class SessionDeferredStartup { return this.state === 'pending' } + get status(): DeferredStartupStatus { + return this.state + } + retire(): void { if (this.state === 'pending') { this.state = 'retired' diff --git a/src/main/daemon/session-startup-input.ts b/src/main/daemon/session-startup-input.ts index 1427bb5ecb4..c4fa0ed289a 100644 --- a/src/main/daemon/session-startup-input.ts +++ b/src/main/daemon/session-startup-input.ts @@ -2,6 +2,7 @@ import type { SessionOptions } from './session-options' import type { SessionOutputPlane } from './session-output-plane' import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import { PtyStartupIngress } from '../../shared/pty-startup-ingress' +import type { DeferredStartupStatus } from '../../shared/deferred-startup-release' import { extractOnlyTerminalQueryReplies } from '../../shared/terminal-query-reply' import { SessionDeferredStartup, @@ -30,6 +31,10 @@ export class SessionStartupInput { this.deferred = deferredStartup ? new SessionDeferredStartup(deferredStartup) : undefined } + get deferredStartupStatus(): DeferredStartupStatus | undefined { + return this.deferred?.status + } + write(data: string): void { if (!this.options.isAlive() || this.options.ingress.answerLiveQueryReply(data)) { return diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 1d4e8726cbf..30b2c32d62f 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -15,6 +15,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import { randomUUID } from 'node:crypto' import type { PtyStartupIngress } from '../../shared/pty-startup-ingress' import type { StartupCommandReleaseResult } from './session-deferred-startup' +import type { DeferredStartupStatus } from '../../shared/deferred-startup-release' import { createSessionStartupInput, type SessionStartupInput } from './session-startup-input' import type { @@ -139,6 +140,10 @@ export class Session { this.input.write(data) } + get deferredStartupStatus(): DeferredStartupStatus | undefined { + return this.input.deferredStartupStatus + } + releaseStartupCommand( expectedIncarnationId: string, operationId: string diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 42f5bf457f4..ee6f8d5ecad 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -1,4 +1,5 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { DeferredStartupStatus } from '../../shared/deferred-startup-release' import type { TuiAgent } from '../../shared/tui-agent' import type { ShellReadyState, TerminalSnapshot } from './types' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' @@ -44,6 +45,7 @@ export type CreateOrAttachOptions = { } export type CreateOrAttachResult = { + deferredStartupStatus?: DeferredStartupStatus isNew: boolean snapshot: TerminalSnapshot | null pid: number | null diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 90b700f24b2..e3a6eb12946 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -1,3 +1,4 @@ +import { ptyOwnership } from '../provider/ownership-state' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { agentHookServer } from '../../../agent-hooks/server' import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' @@ -28,6 +29,10 @@ import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty- export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args + const startupDeferred = + (ctx.result.deferredStartupStatus !== undefined && + ctx.result.deferredStartupStatus !== 'accepted') || + ptyOwnership.hasDeferredStartup(ctx.result.id, ctx.result.incarnationId) const { rendererPreSignaled, rendererAlreadyRegistered, committedSize } = await persistPtyIpcSpawnCommit(ctx) @@ -136,13 +141,13 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise() +export const ptyOwnership = new PtyOwnershipRegistry() export const ptyIncarnationById = new Map() export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean { diff --git a/src/main/ipc/pty/provider/pty-ownership-registry.ts b/src/main/ipc/pty/provider/pty-ownership-registry.ts new file mode 100644 index 00000000000..7d4f9db9bec --- /dev/null +++ b/src/main/ipc/pty/provider/pty-ownership-registry.ts @@ -0,0 +1,65 @@ +type DeferredStartup = { + isClaudeLaunch: boolean + incarnationId: string + operationId: string + onAccepted: () => void +} + +/** Pending launch effects have the same lifetime as their owning PTY route. */ +export class PtyOwnershipRegistry extends Map { + private readonly deferredStartups = new Map() + + override set(id: string, connectionId: string | null): this { + if (this.has(id) && this.get(id) !== connectionId) { + this.deferredStartups.delete(id) + } + return super.set(id, connectionId) + } + + override delete(id: string): boolean { + this.deferredStartups.delete(id) + return super.delete(id) + } + + override clear(): void { + this.deferredStartups.clear() + super.clear() + } + + deferStartup(id: string, startup: DeferredStartup): void { + if (this.has(id)) { + this.deferredStartups.set(id, startup) + } + } + + clearDeferredStartup(id: string): void { + this.deferredStartups.delete(id) + } + + hasDeferredStartup(id: string, incarnationId: string | undefined): boolean { + return ( + incarnationId !== undefined && this.deferredStartups.get(id)?.incarnationId === incarnationId + ) + } + + getDeferredStartup( + id: string, + incarnationId: string, + operationId: string + ): DeferredStartup | undefined { + const startup = this.deferredStartups.get(id) + return startup?.incarnationId === incarnationId && startup.operationId === operationId + ? startup + : undefined + } + + settleDeferredStartup(id: string, startup: DeferredStartup, accepted: boolean): void { + if (this.deferredStartups.get(id) !== startup) { + return + } + this.deferredStartups.delete(id) + if (accepted) { + startup.onAccepted() + } + } +} diff --git a/src/main/ipc/pty/provider/state-cleanup.ts b/src/main/ipc/pty/provider/state-cleanup.ts index 72286eb0a4d..b5cea80057a 100644 --- a/src/main/ipc/pty/provider/state-cleanup.ts +++ b/src/main/ipc/pty/provider/state-cleanup.ts @@ -53,6 +53,7 @@ export function clearProviderPtyState( piTitlebarExtensionService.clearPty(id) // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. markClaudePtyExited(id) + ptyOwnership.clearDeferredStartup(id) ptySizes.delete(id) ptyIncarnationById.delete(id) lastInputAtByPty.delete(id) diff --git a/src/main/ipc/pty/runtime/deferred-startup.ts b/src/main/ipc/pty/runtime/deferred-startup.ts index 81ebfbf89ba..3cdc2412ccb 100644 --- a/src/main/ipc/pty/runtime/deferred-startup.ts +++ b/src/main/ipc/pty/runtime/deferred-startup.ts @@ -3,6 +3,7 @@ import type { IPtyProvider } from '../../../providers/types' import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { getProvider, getProviderForPty } from '../provider/registry' import type { RuntimePtySpawnArgs } from './spawn-state' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' export function providerSupportsDeferredStartup(provider: IPtyProvider): boolean { return Boolean(provider.releaseStartupCommand && provider.supportsDeferredStartupCommands?.()) @@ -56,5 +57,17 @@ export async function releaseStartupFromRuntimeController( if (!provider.releaseStartupCommand) { return 'unavailable' } - return provider.releaseStartupCommand(ptyId, expectedIncarnationId, operationId) + const startup = ptyOwnership.getDeferredStartup(ptyId, expectedIncarnationId, operationId) + if (startup?.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + return 'unavailable' + } + const result = await provider.releaseStartupCommand(ptyId, expectedIncarnationId, operationId) + if ( + startup && + (result === 'accepted' || result === 'retired') && + ptyIncarnationById.get(ptyId) === expectedIncarnationId + ) { + ptyOwnership.settleDeferredStartup(ptyId, startup, result === 'accepted') + } + return result } diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 7f8a9e38267..22cc2ce6a65 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -1,3 +1,4 @@ +import { commitRuntimeStartupEffects } from './spawn-startup-effects' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' @@ -7,7 +8,6 @@ import { recordCodexPaneAccountForSpawn, codexReattachedHomeRouteField } from '../host-env/codex-home' -import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import { rememberPaneKeyForPty } from '../pane/key-state' import { @@ -16,13 +16,6 @@ import { rendererSerializerReadiness } from '../pane/serializer-state' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' -import { track } from '../../../telemetry/client' -import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../../../shared/telemetry-events' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { @@ -221,26 +214,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { } // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) - // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). - if (!ctx.stablePaneOwner) { - ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null) - } - if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { - markClaudePtySpawned(ctx.result.id) - } - if (args.telemetry && !ctx.stablePaneOwner) { - const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) - const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) - const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) - if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { - track('agent_started', { - agent_kind: agentKindParse.data, - launch_source: launchSourceParse.data, - request_kind: requestKindParse.data, - ...getCohortAtEmit() - }) - } - } + commitRuntimeStartupEffects(ctx) // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY) const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined diff --git a/src/main/ipc/pty/runtime/spawn-startup-effects.test.ts b/src/main/ipc/pty/runtime/spawn-startup-effects.test.ts new file mode 100644 index 00000000000..4fae52a3b27 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-startup-effects.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' +import { commitRuntimeStartupEffects } from './spawn-startup-effects' +import { releaseStartupFromRuntimeController } from './deferred-startup' +import { createRuntimePtySpawnState, type RuntimePtySpawnState } from './spawn-state' +import { commitRuntimePtySpawn } from './spawn-commit' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import type { StartupCommandReleaseResult } from '../../../../shared/deferred-startup-release' + +const effects = vi.hoisted(() => ({ + note: vi.fn(), + mark: vi.fn(), + track: vi.fn(), + switching: vi.fn(() => false), + release: vi.fn<() => Promise>() +})) +vi.mock('../../../claude-accounts/live-pty-gate', () => ({ + markClaudePtySpawned: effects.mark, + isClaudeAuthSwitchInProgress: effects.switching +})) +vi.mock('../../../telemetry/client', () => ({ track: effects.track })) +vi.mock('../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) +vi.mock('../provider/registry', () => ({ + localProvider: {}, + getProvider: vi.fn(), + getProviderForPty: () => ({ releaseStartupCommand: effects.release }) +})) + +function prepare(deferred = true): void { + ptyOwnership.set('pty', null) + ptyIncarnationById.set('pty', 'incarnation') + commitRuntimeStartupEffects({ + result: { id: 'pty', incarnationId: 'incarnation' }, + deps: { runtime: { noteTerminalSpawnCommand: effects.note } }, + args: { + ...(deferred ? { deferredStartupOperationId: 'operation' } : {}), + telemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + }, + launchCommand: 'claude', + isClaudeLaunch: true + } as unknown as RuntimePtySpawnState) +} +const release = () => releaseStartupFromRuntimeController('pty', 'incarnation', 'operation') +function expectUnstarted(): void { + expect(effects.note).not.toHaveBeenCalled() + expect(effects.mark).not.toHaveBeenCalled() + expect(effects.track).not.toHaveBeenCalled() +} +function expectStartedOnce(): void { + expect(effects.note).toHaveBeenCalledExactlyOnceWith('pty', 'claude') + expect(effects.mark).toHaveBeenCalledExactlyOnceWith('pty') + expect(effects.track).toHaveBeenCalledExactlyOnceWith('agent_started', { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + }) +} + +beforeEach(() => { + vi.clearAllMocks() + ptyOwnership.clear() + ptyIncarnationById.clear() + effects.release.mockResolvedValue('accepted') + effects.switching.mockReturnValue(false) +}) + +describe('deferred runtime launch effects', () => { + it('rechecks an account switch that began after preparation before releasing Claude', async () => { + prepare() + effects.switching.mockReturnValue(true) + expect(await release()).toBe('unavailable') + expect(effects.release).not.toHaveBeenCalled() + expectUnstarted() + effects.switching.mockReturnValue(false) + expect(await release()).toBe('accepted') + expectStartedOnce() + }) + it('the actual spawn commit holds effects until accepted release', async () => { + const ctx = createRuntimePtySpawnState( + { + runtime: { noteTerminalSpawnCommand: effects.note }, + sendPtySpawnedToRenderer: vi.fn(), + options: {} + } as unknown as PtyRuntimeControllerDeps, + { + cols: 80, + rows: 24, + command: 'claude', + deferredStartupOperationId: 'operation', + telemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + } + ) + ctx.result = { id: 'pty', incarnationId: 'incarnation' } + ctx.launchCommand = 'claude' + ctx.isClaudeLaunch = true + await commitRuntimePtySpawn(ctx) + expectUnstarted() + await release() + expectStartedOnce() + }) + it('keeps ordinary startup effects immediate', () => { + prepare(false) + expectStartedOnce() + }) + it('does not turn a pending shell reattach into an agent launch', async () => { + prepare() + commitRuntimeStartupEffects({ + result: { id: 'pty', incarnationId: 'incarnation', isReattach: true }, + deps: { runtime: { noteTerminalSpawnCommand: effects.note } }, + args: {}, + launchCommand: 'claude', + isClaudeLaunch: true + } as unknown as RuntimePtySpawnState) + expectUnstarted() + await release() + expectStartedOnce() + }) + it('does not count shell preparation as agent startup', () => { + prepare() + expectUnstarted() + }) + it.each(['pending', 'retired', 'unverifiable'] as const)( + 'honors daemon %s status without a main-process pending record', + (deferredStartupStatus) => { + commitRuntimeStartupEffects({ + result: { + id: 'pty', + incarnationId: 'incarnation', + isReattach: true, + deferredStartupStatus + }, + deps: { runtime: { noteTerminalSpawnCommand: effects.note } }, + args: { + telemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + }, + launchCommand: 'claude', + isClaudeLaunch: true + } as unknown as RuntimePtySpawnState) + expectUnstarted() + } + ) + it('publishes once after accepted release, including concurrent retries', async () => { + prepare() + await Promise.all([release(), release(), release()]) + expectStartedOnce() + }) + it.each(['unverifiable', 'unavailable', 'identity-mismatch'] as const)( + 'does not publish for %s; a later accepted receipt publishes once', + async (result) => { + prepare() + effects.release.mockResolvedValueOnce(result) + expect(await release()).toBe(result) + expectUnstarted() + await release() + expectStartedOnce() + } + ) + it('allows a lost acknowledgement to be retried without counting the failed attempt', async () => { + prepare() + effects.release.mockRejectedValueOnce(new Error('connection lost')) + await expect(release()).rejects.toThrow('connection lost') + expectUnstarted() + await release() + await release() + expectStartedOnce() + }) + it('drops a retired command without publishing effects', async () => { + prepare() + effects.release.mockResolvedValueOnce('retired') + await release() + await release() + expectUnstarted() + }) + it.each(['delete', 'clear', 'clearDeferredStartup'] as const)( + 'ownership %s bounds the pending record and fences an in-flight acknowledgement', + async (cleanup) => { + prepare() + let acknowledge!: (value: StartupCommandReleaseResult) => void + effects.release.mockImplementationOnce( + () => + new Promise((resolve) => { + acknowledge = resolve + }) + ) + const pending = release() + ptyOwnership[cleanup]('pty') + acknowledge('accepted') + await pending + expectUnstarted() + } + ) + it('does not publish into a replacement process incarnation', async () => { + prepare() + let acknowledge!: (value: StartupCommandReleaseResult) => void + effects.release.mockImplementationOnce( + () => + new Promise((resolve) => { + acknowledge = resolve + }) + ) + const pending = release() + ptyIncarnationById.set('pty', 'replacement') + acknowledge('accepted') + await pending + expectUnstarted() + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-startup-effects.ts b/src/main/ipc/pty/runtime/spawn-startup-effects.ts new file mode 100644 index 00000000000..7e74fc619d9 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-startup-effects.ts @@ -0,0 +1,53 @@ +import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../../../shared/telemetry-events' +import { ptyOwnership } from '../provider/ownership-state' +import type { RuntimePtySpawnState } from './spawn-state' + +export function commitRuntimeStartupEffects(ctx: RuntimePtySpawnState): void { + if (ctx.stablePaneOwner) { + return + } + const { id, incarnationId } = ctx.result + if ( + ctx.result.isReattach && + ((ctx.result.deferredStartupStatus !== undefined && + ctx.result.deferredStartupStatus !== 'accepted') || + ptyOwnership.hasDeferredStartup(id, incarnationId)) + ) { + return + } + const { runtime } = ctx.deps + const { launchCommand, isClaudeLaunch } = ctx + const agentKind = agentKindSchema.safeParse(ctx.args.telemetry?.agent_kind) + const launchSource = launchSourceSchema.safeParse(ctx.args.telemetry?.launch_source) + const requestKind = requestKindSchema.safeParse(ctx.args.telemetry?.request_kind) + const onAccepted = (): void => { + runtime?.noteTerminalSpawnCommand?.(id, launchCommand ?? null) + if (isClaudeLaunch) { + markClaudePtySpawned(id) + } + if (agentKind.success && launchSource.success && requestKind.success) { + track('agent_started', { + agent_kind: agentKind.data, + launch_source: launchSource.data, + request_kind: requestKind.data, + ...getCohortAtEmit() + }) + } + } + const operationId = ctx.args.deferredStartupOperationId + ptyOwnership.clearDeferredStartup(id) + if (operationId !== undefined) { + if (incarnationId) { + ptyOwnership.deferStartup(id, { incarnationId, operationId, isClaudeLaunch, onAccepted }) + } + return + } + onAccepted() +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index b4f8992049d..3e1a1b51b79 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1,3 +1,4 @@ +import { supportsDeferredStartupFromRuntimeController } from './pty/runtime/deferred-startup' /* eslint-disable max-lines */ // Why: worktree create helpers (local + remote) split out of worktrees.ts; the cohesive create flow runs this file just over the per-file line limit. @@ -402,6 +403,13 @@ async function spawnLocalStartupAndSetupTerminals(args: { return { didSpawnSetup: false } } + if ( + startup.deferredStartupOperationId !== undefined && + !(await supportsDeferredStartupFromRuntimeController(null)) + ) { + return { didSpawnSetup: false } + } + let warning: string | undefined let startupTerminalHandle: string | null = null let startupTerminal: CreateWorktreeResult['startupTerminal'] @@ -445,6 +453,8 @@ async function spawnLocalStartupAndSetupTerminals(args: { } const terminal = await runtime.createTerminal(`id:${worktree.id}`, { command: sequencedStartup.command, + deferredStartupOperationId: sequencedStartup.deferredStartupOperationId, + launchToken: sequencedStartup.launchToken, ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), env: sequencedStartup.env, ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), @@ -462,7 +472,15 @@ async function spawnLocalStartupAndSetupTerminals(args: { tabId: terminal.tabId, paneKey: terminal.paneKey, ptyId: terminal.ptyId, - surface: terminal.surface + surface: terminal.surface, + ...(sequencedStartup.deferredStartupOperationId + ? { + deferredStartup: { + operationId: sequencedStartup.deferredStartupOperationId, + incarnationId: terminal.incarnationId ?? null + } + } + : {}) } } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -1849,6 +1867,9 @@ export async function createRemoteWorktree( store: Store, mainWindow: BrowserWindow ): Promise { + if (args.startup?.deferredStartupOperationId !== undefined) { + throw new Error('deferred_startup_unavailable') + } const timing = createWorktreeCreateTimingRecorder() const provider = requireSshGitProvider(repo.connectionId!) const fsProvider = getSshFilesystemProvider(repo.connectionId!) diff --git a/src/main/ipc/worktrees-deferred-startup-create.test.ts b/src/main/ipc/worktrees-deferred-startup-create.test.ts new file mode 100644 index 00000000000..73c989ed4df --- /dev/null +++ b/src/main/ipc/worktrees-deferred-startup-create.test.ts @@ -0,0 +1,165 @@ +import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WorktreeLogic from './worktree-logic' +import { addWorktreeMock, listWorktreesMock } from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers } from './worktrees-test-harness' +import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('../providers/ssh-git-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() +) +vi.mock('../providers/ssh-filesystem-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('./worktree-logic', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(actual), + computeWorkspaceRootAsync: vi.fn(actual.computeWorkspaceRootAsync) + } +}) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) + +const supportsDeferredStartupMock = vi.hoisted(() => vi.fn(async () => true)) +vi.mock('./pty/runtime/deferred-startup', () => ({ + supportsDeferredStartupFromRuntimeController: supportsDeferredStartupMock, + releaseStartupFromRuntimeController: vi.fn() +})) + +describe('deferred composer startup creation', () => { + let runtimeStub: WorktreeRuntimeStub + beforeEach(() => { + runtimeStub = setupWorktreeHandlers() + supportsDeferredStartupMock.mockResolvedValue(true) + }) + function stubStartupWorktreeListing(): void { + listWorktreesMock.mockResolvedValueOnce([ + { + path: '/workspace/improve-dashboard', + branch: 'improve-dashboard', + head: 'def', + isBare: false, + isMainWorktree: false + } + ]) + } + + it.each(['incarnation', undefined])( + 'preserves the held operation with incarnation %s', + async (incarnationId) => { + addWorktreeMock.mockResolvedValue({}) + stubStartupWorktreeListing() + runtimeStub.createTerminal.mockResolvedValueOnce({ + handle: 'term-startup', + ptyId: 'pty', + incarnationId + }) + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + startup: { + command: 'claude', + activate: false, + deferredStartupOperationId: 'operation', + launchToken: 'operation' + } + })) as CreateWorktreeResult + expect(runtimeStub.createTerminal).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + command: 'claude', + deferredStartupOperationId: 'operation', + launchToken: 'operation', + activate: false, + surfaceOwner: false + }) + ) + expect(result.startupTerminal).toMatchObject({ + ptyId: 'pty', + deferredStartup: { operationId: 'operation', incarnationId: incarnationId ?? null } + }) + } + ) + + it('keeps checkout-only preparation when provider capability changed before spawn', async () => { + supportsDeferredStartupMock.mockResolvedValue(false) + addWorktreeMock.mockResolvedValue({}) + stubStartupWorktreeListing() + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + startup: { command: 'claude', activate: false, deferredStartupOperationId: 'operation' } + })) as CreateWorktreeResult + expect(runtimeStub.createTerminal).not.toHaveBeenCalled() + expect(result.startupTerminal).toBeUndefined() + expect(result.worktree).toBeDefined() + }) +}) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 140657e1669..52ba810a76b 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1,3 +1,4 @@ +import { registerWorktreeDeferredStartupHandlers } from './worktrees/create/register-worktree-deferred-startup-handlers' import { ipcMain, type BrowserWindow } from 'electron' import type { Store } from '../persistence/loading-store/store' import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' @@ -38,6 +39,8 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:forgetRemovedForExecutionHost', 'worktrees:cancelListDetected', 'worktrees:create', + 'worktrees:supportsDeferredStartup', + 'worktrees:releaseStartup', 'worktrees:adoptProvisionedRoot', 'worktrees:prefetchCreateBase', 'worktrees:prepareCreateCheckout', @@ -92,6 +95,7 @@ export function registerWorktreeHandlers( disposeWorktreeStandbys?.() disposeWorktreeStandbys = registerWorktreePrefetchHandler(context) registerWorktreeCreateHandlers(context) + registerWorktreeDeferredStartupHandlers(context) registerReviewBaseHandlers(context) registerWorktreeRemovalHandlers(context) registerWorktreeForgetHandlers(context) diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts index 1f94598208b..65f724a1410 100644 --- a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -1,3 +1,4 @@ +import { isNativeDeferredStartupRepo } from './register-worktree-deferred-startup-handlers' import { ipcMain, app } from 'electron' import type { CreateWorktreeArgs, @@ -46,6 +47,13 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi throw new Error(`Repo not found: ${args.repoId}`) } + if ( + args.startup?.deferredStartupOperationId !== undefined && + !isNativeDeferredStartupRepo(repo) + ) { + throw new Error('deferred_startup_unavailable') + } + const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' diff --git a/src/main/ipc/worktrees/create/register-worktree-deferred-startup-handlers.test.ts b/src/main/ipc/worktrees/create/register-worktree-deferred-startup-handlers.test.ts new file mode 100644 index 00000000000..0c38fb7219a --- /dev/null +++ b/src/main/ipc/worktrees/create/register-worktree-deferred-startup-handlers.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { makePaneKey } from '../../../../shared/stable-pane-id' +const { handlers, supports, release, ownership } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + supports: vi.fn(async () => true), + release: vi.fn(async () => 'accepted'), + ownership: new Map() +})) +vi.mock('electron', () => ({ + ipcMain: { handle: (name: string, fn: (...args: unknown[]) => unknown) => handlers.set(name, fn) } +})) +vi.mock('../../pty/provider/ownership-state', () => ({ ptyOwnership: ownership })) +vi.mock('../../pty/runtime/deferred-startup', () => ({ + supportsDeferredStartupFromRuntimeController: supports, + releaseStartupFromRuntimeController: release +})) +vi.mock('../../pty/pane/stable-owner', () => ({ + resolvePersistedStablePaneOwner: (store: WorktreeIpcContext['store'], key: string) => ({ + incarnationId: store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[key] + }) +})) +import { registerWorktreeDeferredStartupHandlers } from './register-worktree-deferred-startup-handlers' + +describe('composer deferred startup admission and release', () => { + const repo = { id: 'repo', path: '/repo', name: 'repo' } + const session = { + tabsByWorktree: { workspace: [{ id: 'tab', worktreeId: 'workspace' }] }, + terminalLayoutsByTabId: { + tab: { ptyIdsByLeafId: { '11111111-1111-4111-8111-111111111111': 'pty' } } + }, + terminalPtyIncarnationsByPaneKey: { + [makePaneKey('tab', '11111111-1111-4111-8111-111111111111')]: 'incarnation' + } + } + const store = { getRepo: vi.fn((): unknown => repo), getWorkspaceSession: () => session } + const args = { + worktreeId: 'workspace', + ptyId: 'pty', + expectedIncarnationId: 'incarnation', + operationId: 'operation' + } + beforeEach(() => { + vi.clearAllMocks() + supports.mockResolvedValue(true) + ownership.clear() + ownership.set('pty', null) + store.getRepo.mockReturnValue(repo) + registerWorktreeDeferredStartupHandlers({ store } as unknown as WorktreeIpcContext) + }) + it('requires a supporting provider for the local repo', async () => { + expect(await handlers.get('worktrees:supportsDeferredStartup')!(null, 'repo')).toBe(true) + supports.mockResolvedValue(false) + expect(await handlers.get('worktrees:supportsDeferredStartup')!(null, 'repo')).toBe(false) + }) + it.each([ + { executionHostId: 'ssh:host' }, + { executionHostId: 'runtime:host' }, + { kind: 'folder' } + ])('never probes a nonnative owner %j', async (extra) => { + store.getRepo.mockReturnValue({ ...repo, ...extra }) + expect(await handlers.get('worktrees:supportsDeferredStartup')!(null, 'repo')).toBe(false) + expect(supports).not.toHaveBeenCalled() + }) + it('releases only the exact persisted workspace process', async () => { + expect(await handlers.get('worktrees:releaseStartup')!(null, args)).toBe('accepted') + expect(release).toHaveBeenCalledWith('pty', 'incarnation', 'operation') + }) + it.each([{ worktreeId: 'other' }, { ptyId: 'other' }, { expectedIncarnationId: 'old' }])( + 'does not release a mismatched identity %j', + async (override) => { + expect( + await handlers.get('worktrees:releaseStartup')!(null, { ...args, ...override }) + ).not.toBe('accepted') + expect(release).not.toHaveBeenCalled() + } + ) + it('does not route a retained remote process through local release', async () => { + ownership.set('pty', 'ssh') + expect(await handlers.get('worktrees:releaseStartup')!(null, args)).toBe('unavailable') + expect(release).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktrees/create/register-worktree-deferred-startup-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-deferred-startup-handlers.ts new file mode 100644 index 00000000000..a047ba984e2 --- /dev/null +++ b/src/main/ipc/worktrees/create/register-worktree-deferred-startup-handlers.ts @@ -0,0 +1,57 @@ +import { ipcMain } from 'electron' +import { ptyOwnership } from '../../pty/provider/ownership-state' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeStartupReleaseArgs } from '../../../../shared/worktree/launch-types' +import { resolveWorktreeCreateRoute } from '../../../worktree-create-execution-host-route' +import { resolvePersistedStablePaneOwner } from '../../pty/pane/stable-owner' +import { + supportsDeferredStartupFromRuntimeController, + releaseStartupFromRuntimeController +} from '../../pty/runtime/deferred-startup' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function isNativeDeferredStartupRepo(repo: Repo): boolean { + return !isFolderRepo(repo) && resolveWorktreeCreateRoute(repo).kind === 'local' +} + +export function registerWorktreeDeferredStartupHandlers(context: WorktreeIpcContext): void { + ipcMain.handle('worktrees:supportsDeferredStartup', async (_event, repoId: string) => { + const repo = context.store.getRepo(repoId) + return Boolean( + repo && + isNativeDeferredStartupRepo(repo) && + (await supportsDeferredStartupFromRuntimeController(null)) + ) + }) + ipcMain.handle('worktrees:releaseStartup', async (_event, args: WorktreeStartupReleaseArgs) => { + if (ptyOwnership.get(args.ptyId) !== null) { + return 'unavailable' + } + const session = context.store.getWorkspaceSession() + for (const tab of session.tabsByWorktree?.[args.worktreeId] ?? []) { + const layout = session.terminalLayoutsByTabId?.[tab.id] + for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) { + if (ptyId !== args.ptyId) { + continue + } + const owner = resolvePersistedStablePaneOwner( + context.store, + makePaneKey(tab.id, leafId), + args.worktreeId, + null + ) + if (!owner || owner.incarnationId !== args.expectedIncarnationId) { + return 'identity-mismatch' + } + return releaseStartupFromRuntimeController( + args.ptyId, + args.expectedIncarnationId, + args.operationId + ) + } + } + return 'unavailable' + }) +} diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 56de0b89115..501c2d5a3db 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -2,10 +2,12 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { DeferredStartupStatus } from '../../shared/deferred-startup-release' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import type { TerminalOwner } from '../../shared/terminal-owner' export type PtySpawnResult = { + deferredStartupStatus?: DeferredStartupStatus agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index 29b29f33db9..6f78a7158b3 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -1,3 +1,5 @@ +import type { WorktreeStartupReleaseArgs } from '../../shared/worktree/launch-types' +import type { StartupCommandReleaseResult } from '../../shared/deferred-startup-release' import type { ForgetRemovedWorktreesForExecutionHostArgs, ForgetRemovedWorktreesForExecutionHostResult, @@ -42,6 +44,8 @@ import type { } from '../../shared/worktree/types' export type WorktreeApi = { + supportsDeferredStartup?: (repoId: string) => Promise + releaseStartup?: (args: WorktreeStartupReleaseArgs) => Promise list: (args: { repoId: string }) => Promise /** Generated names already spent in this repo, including deleted workspaces. Name suggestions * exclude these so a recreated workspace never lands on a prior occupant's path. Compacted: a diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index 879633b73db..61b14ed11e4 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -41,6 +41,9 @@ export const worktreesApi = { return () => ipcRenderer.removeListener('createWorktree:progress', listener) }, + supportsDeferredStartup: (repoId) => + ipcRenderer.invoke('worktrees:supportsDeferredStartup', repoId), + releaseStartup: (args) => ipcRenderer.invoke('worktrees:releaseStartup', args), setCreateStandby: (args) => ipcRenderer.invoke('worktrees:setCreateStandby', args), prepareCreateCheckout: (args) => ipcRenderer.invoke('worktrees:prepareCreateCheckout', args), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f281201638b..819c26b5934 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1,4 +1,11 @@ { + "workspace": { + "create": { + "preparedTerminalUnverified": "Could not verify the prepared terminal. Your workspace is saved; open it to continue.", + "preparedTerminalChanged": "This terminal has changed. Your workspace is saved; open it to continue.", + "preparedAgentUnconfirmed": "Could not confirm the agent started. Your workspace is saved. Retry to check again." + } + }, "app": { "recoverableError": { "rootTitle": "Orca hit a renderer error.", diff --git a/src/renderer/src/lib/composer-deferred-startup.ts b/src/renderer/src/lib/composer-deferred-startup.ts new file mode 100644 index 00000000000..808f4f801fe --- /dev/null +++ b/src/renderer/src/lib/composer-deferred-startup.ts @@ -0,0 +1,37 @@ +import type { WorktreeCreationRequest } from './pending-worktree-creation' +import { resolveBackendDraftStartup } from './worktree-draft-startup-view-mode' +import { isWebClientLocation } from './web-client-location' + +export async function prepareComposerStartup( + creationId: string, + request: WorktreeCreationRequest +): Promise { + const plan = request.startupPlan + if (!request.agent || !plan || typeof window === 'undefined' || isWebClientLocation()) { + return undefined + } + try { + if (!(await window.api?.worktrees?.supportsDeferredStartup?.(request.repoId))) { + return undefined + } + } catch { + return undefined + } + const startup = request.startup ?? { + command: plan.launchCommand, + env: plan.env, + launchConfig: plan.launchConfig, + launchAgent: request.agent, + startupCommandDelivery: plan.startupCommandDelivery, + ...(request.quickTelemetry ? { telemetry: request.quickTelemetry } : {}) + } + return resolveBackendDraftStartup({ + ...request, + startup: { + ...startup, + activate: false, + launchToken: creationId, + deferredStartupOperationId: creationId + } + }) +} diff --git a/src/renderer/src/lib/create-requested-worktree.test.ts b/src/renderer/src/lib/create-requested-worktree.test.ts index 42b4f6d7500..330a484dfac 100644 --- a/src/renderer/src/lib/create-requested-worktree.test.ts +++ b/src/renderer/src/lib/create-requested-worktree.test.ts @@ -10,7 +10,10 @@ vi.mock('@/lib/worktree-draft-startup-view-mode', () => ({ resolveBackendDraftStartup: (request: { startup?: unknown }) => request.startup })) -beforeEach(() => vi.clearAllMocks()) +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('window', { location: { pathname: '/' }, api: { worktrees: {} } }) +}) describe('durable composer creation launch boundary', () => { it.each(['command', 'draft'] as const)( @@ -38,6 +41,98 @@ describe('durable composer creation launch boundary', () => { } ) + it('prepares the original agent command behind an owner capability without mutating the request', async () => { + const supportsDeferredStartup = vi.fn().mockResolvedValue(true) + vi.stubGlobal('window', { + location: { pathname: '/' }, + api: { worktrees: { supportsDeferredStartup } } + }) + const request = makeRequest({ + agent: 'codex', + startupPlan: { + agent: 'codex', + launchCommand: 'codex --model fixture', + expectedProcess: 'codex', + followupPrompt: null, + env: { PROJECT: 'fixture' }, + launchConfig: { agentArgs: '--model fixture', agentEnv: { PROJECT: 'fixture' } } + } + }) + const snapshot = structuredClone(request) + + await createRequestedWorktree('reservation', request, true) + + expect(supportsDeferredStartup).toHaveBeenCalledWith('repo-1') + expect(createWorktree.mock.calls[0][16]).toMatchObject({ + command: 'codex --model fixture', + launchAgent: 'codex', + env: { PROJECT: 'fixture' }, + launchToken: 'reservation', + deferredStartupOperationId: 'reservation', + activate: false + }) + expect(request).toEqual(snapshot) + }) + + it.each(['unsupported', 'disconnected'])( + 'keeps checkout-only agent preparation when the owner is %s', + async (owner) => { + const supportsDeferredStartup = vi.fn() + if (owner === 'unsupported') { + supportsDeferredStartup.mockResolvedValue(false) + } else { + supportsDeferredStartup.mockRejectedValue(new Error('connection lost')) + } + vi.stubGlobal('window', { + location: { pathname: '/' }, + api: { worktrees: { supportsDeferredStartup } } + }) + await createRequestedWorktree( + 'reservation', + makeRequest({ + agent: 'codex', + startup: { command: 'codex', launchAgent: 'codex' }, + launchDraftPrompt: 'Unsent task', + startupPlan: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + } + }), + true + ) + expect(createWorktree.mock.calls[0][16]).toBeUndefined() + expect(createWorktree.mock.calls[0][25]).not.toHaveProperty('startupDraft') + } + ) + + it('never asks a paired web client to prepare a local agent shell', async () => { + const supportsDeferredStartup = vi.fn(async () => true) + vi.stubGlobal('window', { + __ORCA_WEB_CLIENT__: true, + location: { pathname: '/' }, + api: { worktrees: { supportsDeferredStartup } } + }) + await createRequestedWorktree( + 'reservation', + makeRequest({ + agent: 'codex', + startupPlan: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + } + }), + true + ) + expect(supportsDeferredStartup).not.toHaveBeenCalled() + expect(createWorktree.mock.calls[0][16]).toBeUndefined() + }) + it('preserves ordinary backend agent launch', async () => { const startup = { command: 'codex', launchAgent: 'codex' as const } await createRequestedWorktree('submit', makeRequest({ agent: 'codex', startup })) diff --git a/src/renderer/src/lib/create-requested-worktree.ts b/src/renderer/src/lib/create-requested-worktree.ts index fecd1cec579..23485b08ae8 100644 --- a/src/renderer/src/lib/create-requested-worktree.ts +++ b/src/renderer/src/lib/create-requested-worktree.ts @@ -3,9 +3,10 @@ import { getProvisionedRootCreateOptions } from '@/lib/provisioned-root-create-o import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' +import { prepareComposerStartup } from './composer-deferred-startup' /** Registers a durable workspace without revealing it or running renderer launch actions. */ -export function createRequestedWorktree( +export async function createRequestedWorktree( creationId: string, preparedRequest: WorktreeCreationRequest, background = false @@ -14,9 +15,11 @@ export function createRequestedWorktree( const structuredLaunch = preparedRequest.agentLaunchRoute === 'structured-native-chat' const deferAgentLaunch = background && preparedRequest.agent !== null const backendStartup = - provisionedRoot || structuredLaunch || deferAgentLaunch + provisionedRoot || structuredLaunch ? undefined - : resolveBackendDraftStartup(preparedRequest) + : deferAgentLaunch + ? await prepareComposerStartup(creationId, preparedRequest) + : resolveBackendDraftStartup(preparedRequest) return useAppStore .getState() .createWorktree( diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index e4959543002..30e42c884d5 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -2,6 +2,7 @@ import type { TuiAgent } from '../../../shared/tui-agent' import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source' import type { CreateSparseCheckoutRequest, + CreateWorktreeResult, SetupDecision } from '../../../shared/worktree/create-types' import type { WorktreeStartupLaunch } from '../../../shared/worktree/launch-types' @@ -137,6 +138,8 @@ export type PendingWorktreeCreation = { provisioningLog?: string /** Existing worktree whose uncertain structured launch must be reconciled instead of recreated. */ structuredLaunchRecoveryWorktreeId?: string + /** Retry an uncertain release on the same retained workspace and terminal. */ + deferredStartupRecovery?: CreateWorktreeResult request: WorktreeCreationRequest } diff --git a/src/renderer/src/lib/worktree-creation-completion.ts b/src/renderer/src/lib/worktree-creation-completion.ts index 931632c5d9e..e58d8c4d515 100644 --- a/src/renderer/src/lib/worktree-creation-completion.ts +++ b/src/renderer/src/lib/worktree-creation-completion.ts @@ -14,6 +14,7 @@ export async function completeWorktreeCreation(args: { primaryTabId: string | null startupTerminalTabId?: string backendSpawned: boolean + deliverBackendStartup?: boolean focusOnCompletion: boolean }): Promise { const { request } = args @@ -28,10 +29,14 @@ export async function completeWorktreeCreation(args: { backendSpawned: args.backendSpawned }) } - if (!args.structuredLaunchAccepted && request.startupPlan && !args.backendSpawned) { + if ( + !args.structuredLaunchAccepted && + request.startupPlan && + (!args.backendSpawned || args.deliverBackendStartup) + ) { void ensureAgentStartupInTerminal({ worktreeId: args.worktreeId, - primaryTabId: args.primaryTabId, + primaryTabId: args.deliverBackendStartup ? args.startupTerminalTabId : args.primaryTabId, startup: request.startupPlan }) } diff --git a/src/renderer/src/lib/worktree-creation-deferred-startup.test.ts b/src/renderer/src/lib/worktree-creation-deferred-startup.test.ts new file mode 100644 index 00000000000..584a37333ba --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-deferred-startup.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PendingWorktreeCreation } from './pending-worktree-creation' +import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' +import { makePendingCreation, makeRequest } from './worktree-creation-request.test-fixture' + +const store = { + settings: { activeRuntimeEnvironmentId: null }, + activeView: 'terminal', + activePendingCreationId: 'creation-1', + repos: [{ id: 'repo-1', connectionId: null }], + pendingWorktreeCreations: {} as Record, + updatePendingWorktreeCreation: vi.fn((id: string, patch: Partial) => { + const entry = store.pendingWorktreeCreations[id] + if (entry) { + store.pendingWorktreeCreations[id] = { ...entry, ...patch } + } + }), + removePendingWorktreeCreation: vi.fn((id: string) => { + delete store.pendingWorktreeCreations[id] + }), + setActivePendingWorktreeCreation: vi.fn(), + setActiveView: vi.fn(), + setSidebarOpen: vi.fn(), + createWorktree: vi.fn(), + updateWorktreeMeta: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + setTabViewMode: vi.fn(), + tabsByWorktree: {}, + unifiedTabsByWorktree: {} +} +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn(() => ({ primaryTabId: 'unrelated-default-tab' })) +})) +vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ + ensureWorktreeHasInitialTerminal: vi.fn() +})) +vi.mock('@/lib/workspace-activation-terminal-focus', () => ({ + queueWorkspaceActivationTerminalFocus: vi.fn() +})) +vi.mock('@/lib/new-workspace', () => ({ ensureAgentStartupInTerminal: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +import { executeWorktreeCreation } from './worktree-creation-flow-execute' +import { retryBackgroundWorktreeCreation } from './worktree-creation-flow' +import { activateAndRevealWorktree } from './worktree-activation' +import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' +import { ensureAgentStartupInTerminal } from './new-workspace' + +const releaseStartup = vi.fn() +const markTrusted = vi.fn() +const request = makeRequest({ + agent: 'codex', + startupPlan: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + } +}) +const retained = { + worktree: { id: 'wt-ready', repoId: 'repo-1', path: '/repo/ready' }, + startupTerminal: { + spawned: true, + tabId: 'held-agent-tab', + ptyId: 'held-pty', + deferredStartup: { operationId: 'held-operation', incarnationId: 'held-incarnation' } + } +} as CreateWorktreeResult +const releaseIdentity = { + worktreeId: 'wt-ready', + ptyId: 'held-pty', + expectedIncarnationId: 'held-incarnation', + operationId: 'held-operation' +} + +beforeEach(() => { + vi.clearAllMocks() + releaseStartup.mockReset().mockResolvedValue('accepted') + markTrusted.mockReset().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { worktrees: { releaseStartup }, agentTrust: { markTrusted } } }) + store.activeView = 'terminal' + store.pendingWorktreeCreations = { 'creation-1': makePendingCreation(structuredClone(request)) } +}) + +describe('retained agent release through worktree creation', () => { + it('waits for trust before release and activates without queuing a second launch', async () => { + let finishTrust!: () => void + markTrusted.mockImplementationOnce( + () => + new Promise((resolve) => { + finishTrust = resolve + }) + ) + const creation = executeWorktreeCreation('creation-1', structuredClone(request), retained) + await vi.waitFor(() => expect(markTrusted).toHaveBeenCalledOnce()) + expect(releaseStartup).not.toHaveBeenCalled() + expect(activateAndRevealWorktree).not.toHaveBeenCalled() + finishTrust() + await creation + expect(releaseStartup).toHaveBeenCalledExactlyOnceWith(releaseIdentity) + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-ready', { + sidebarRevealBehavior: 'auto', + backendStartupTerminalSpawned: true + }) + expect(ensureAgentStartupInTerminal).not.toHaveBeenCalled() + expect(store.createWorktree).not.toHaveBeenCalled() + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + }) + + it('does not release a create canceled while trust is pending', async () => { + let finishTrust!: () => void + markTrusted.mockImplementationOnce( + () => + new Promise((resolve) => { + finishTrust = resolve + }) + ) + const creation = executeWorktreeCreation('creation-1', structuredClone(request), retained) + await vi.waitFor(() => expect(markTrusted).toHaveBeenCalledOnce()) + delete store.pendingWorktreeCreations['creation-1'] + finishTrust() + await creation + expect(releaseStartup).not.toHaveBeenCalled() + expect(activateAndRevealWorktree).not.toHaveBeenCalled() + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + }) + + it('does not revive a create canceled while its release acknowledgement is pending', async () => { + let acknowledge!: (result: string) => void + releaseStartup.mockImplementationOnce( + () => + new Promise((resolve) => { + acknowledge = resolve + }) + ) + const creation = executeWorktreeCreation('creation-1', structuredClone(request), retained) + await vi.waitFor(() => expect(releaseStartup).toHaveBeenCalledOnce()) + delete store.pendingWorktreeCreations['creation-1'] + acknowledge('accepted') + await creation + expect(activateAndRevealWorktree).not.toHaveBeenCalled() + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(ensureAgentStartupInTerminal).not.toHaveBeenCalled() + }) + + it('retains an unconfirmed prepared terminal without falling back when its incarnation is missing', async () => { + const unknown = structuredClone(retained) + unknown.startupTerminal!.deferredStartup!.incarnationId = null + await executeWorktreeCreation('creation-1', structuredClone(request), unknown) + expect(store.pendingWorktreeCreations['creation-1']).toMatchObject({ + status: 'error', + deferredStartupRecovery: unknown, + error: 'Could not verify the prepared terminal. Your workspace is saved; open it to continue.' + }) + expect(releaseStartup).not.toHaveBeenCalled() + expect(store.createWorktree).not.toHaveBeenCalled() + expect(ensureAgentStartupInTerminal).not.toHaveBeenCalled() + }) + + it('holds the saved workspace when an older renderer bridge lacks release support', async () => { + vi.stubGlobal('window', { api: { worktrees: {}, agentTrust: { markTrusted } } }) + await executeWorktreeCreation('creation-1', structuredClone(request), retained) + expect(store.pendingWorktreeCreations['creation-1']).toMatchObject({ + status: 'error', + deferredStartupRecovery: retained + }) + expect(store.createWorktree).not.toHaveBeenCalled() + expect(activateAndRevealWorktree).not.toHaveBeenCalled() + expect(ensureAgentStartupInTerminal).not.toHaveBeenCalled() + }) + + it.each(['unverifiable', 'retired', 'identity-mismatch', 'unavailable', 'disconnect'])( + 'retains the exact workspace on %s and retries release without creating or typing a fallback', + async (outcome) => { + if (outcome === 'disconnect') { + releaseStartup.mockRejectedValueOnce(new Error('connection lost')) + } else { + releaseStartup.mockResolvedValueOnce(outcome) + } + await executeWorktreeCreation('creation-1', structuredClone(request), retained) + expect(store.pendingWorktreeCreations['creation-1']).toMatchObject({ + status: 'error', + deferredStartupRecovery: retained + }) + expect(activateAndRevealWorktree).not.toHaveBeenCalled() + expect(ensureAgentStartupInTerminal).not.toHaveBeenCalled() + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + retryBackgroundWorktreeCreation('creation-1') + await vi.waitFor(() => expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined()) + expect(releaseStartup.mock.calls).toEqual([[releaseIdentity], [releaseIdentity]]) + expect(store.createWorktree).not.toHaveBeenCalled() + expect(ensureAgentStartupInTerminal).not.toHaveBeenCalled() + } + ) + + it('delivers followup context to the held agent tab with its original launch token', async () => { + const withFollowup = structuredClone(request) + withFollowup.startupPlan!.followupPrompt = 'Investigate the issue' + const snapshot = structuredClone(withFollowup) + await executeWorktreeCreation('creation-1', withFollowup, retained) + expect(ensureAgentStartupInTerminal).toHaveBeenCalledExactlyOnceWith({ + worktreeId: 'wt-ready', + primaryTabId: 'held-agent-tab', + startup: { ...snapshot.startupPlan, launchToken: 'held-operation' } + }) + expect(withFollowup).toEqual(snapshot) + expect(store.createWorktree).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 934fa34bec0..afafeac2240 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -21,6 +21,7 @@ import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-st import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session' import { completeWorktreeCreation } from '@/lib/worktree-creation-completion' import { markStructuredWorktreeLaunchUnconfirmed } from '@/lib/worktree-creation-structured-recovery' +import { releasePreparedWorktreeStartup } from './worktree-deferred-startup-release' // Why: activePendingCreationId can outlive the terminal route when the user // switches app views; only the terminal route renders the creation panel. @@ -46,7 +47,7 @@ export async function executeWorktreeCreation( request: WorktreeCreationRequest, retainedCreation?: CreateWorktreeResult | Promise ): Promise { - const preparedRequest = + let preparedRequest = request.ephemeralVmRecipe && !request.ephemeralVmRuntimeId ? await prepareRequestForCreate(creationId, request) : request @@ -56,6 +57,8 @@ export async function executeWorktreeCreation( let result: CreateWorktreeResult try { + retainedCreation ??= + useAppStore.getState().pendingWorktreeCreations[creationId]?.deferredStartupRecovery result = retainedCreation && 'worktree' in retainedCreation ? retainedCreation @@ -99,6 +102,15 @@ export async function executeWorktreeCreation( } const backendSpawned = result.startupTerminal?.spawned === true + if (preparedRequest.startupPlan && result.startupTerminal?.deferredStartup) { + preparedRequest = { + ...preparedRequest, + startupPlan: { + ...preparedRequest.startupPlan, + launchToken: result.startupTerminal.deferredStartup.operationId + } + } + } if (preparedRequest.startupPlan && !backendSpawned && !preparedRequest.startupPlan.launchToken) { // Why: delayed delivery must target the exact pane spawned from this queued // startup, so both halves of the handoff share one renderer-session token. @@ -117,6 +129,13 @@ export async function executeWorktreeCreation( await preflightAgentTrust(preparedRequest, worktree.path, repoConnectionId) } + if ( + result.startupTerminal?.deferredStartup && + !(await releasePreparedWorktreeStartup(creationId, result)) + ) { + return + } + // `createWorktree` already inserted the real worktree row. Leaving for an app // view keeps the create in the background, while selecting another workspace // means the user still expects this task-launch handoff when it becomes ready; @@ -197,6 +216,10 @@ export async function executeWorktreeCreation( primaryTabId, startupTerminalTabId: result.startupTerminal?.tabId, backendSpawned, + deliverBackendStartup: Boolean( + result.startupTerminal?.deferredStartup && + (preparedRequest.startupPlan?.draftPrompt || preparedRequest.startupPlan?.followupPrompt) + ), focusOnCompletion: shouldActivateOnCompletion }) } diff --git a/src/renderer/src/lib/worktree-deferred-startup-release.ts b/src/renderer/src/lib/worktree-deferred-startup-release.ts new file mode 100644 index 00000000000..7b83286e812 --- /dev/null +++ b/src/renderer/src/lib/worktree-deferred-startup-release.ts @@ -0,0 +1,59 @@ +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' +import type { StartupCommandReleaseResult } from '../../../shared/deferred-startup-release' + +export async function releasePreparedWorktreeStartup( + creationId: string, + result: CreateWorktreeResult +): Promise { + const terminal = result.startupTerminal + const deferred = terminal?.deferredStartup + if (!deferred) { + return true + } + const state = useAppStore.getState() + if (!state.pendingWorktreeCreations[creationId]) { + return false + } + state.updatePendingWorktreeCreation(creationId, { deferredStartupRecovery: result }) + let release: StartupCommandReleaseResult = 'unavailable' + try { + if (terminal.ptyId && deferred.incarnationId && window.api?.worktrees?.releaseStartup) { + release = await window.api.worktrees.releaseStartup({ + worktreeId: result.worktree.id, + ptyId: terminal.ptyId, + expectedIncarnationId: deferred.incarnationId, + operationId: deferred.operationId + }) + } + } catch { + release = 'unverifiable' + } + const current = useAppStore.getState() + if (!current.pendingWorktreeCreations[creationId]) { + return false + } + if (release === 'accepted') { + return true + } + current.updatePendingWorktreeCreation(creationId, { + status: 'error', + error: + !terminal.ptyId || !deferred.incarnationId + ? translate( + 'workspace.create.preparedTerminalUnverified', + 'Could not verify the prepared terminal. Your workspace is saved; open it to continue.' + ) + : release === 'retired' || release === 'identity-mismatch' + ? translate( + 'workspace.create.preparedTerminalChanged', + 'This terminal has changed. Your workspace is saved; open it to continue.' + ) + : translate( + 'workspace.create.preparedAgentUnconfirmed', + 'Could not confirm the agent started. Your workspace is saved. Retry to check again.' + ) + }) + return false +} diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 3fa3219fa32..864a15a5621 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -222,6 +222,7 @@ export type WorktreeSlice = { request?: PendingWorktreeCreation['request'] provisioningLog?: string structuredLaunchRecoveryWorktreeId?: string + deferredStartupRecovery?: CreateWorktreeResult } ) => void /** Drop a pending entry, clearing the active surface if it pointed at this diff --git a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts index b4f8bc75bc8..92dc27a8b61 100644 --- a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts @@ -1,4 +1,5 @@ import type { WorktreeSlice } from '../../worktree-helpers' +import { isWebClientLocation } from '@/lib/web-client-location' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types' import { @@ -44,6 +45,12 @@ async function runCreateAttempt( attempt: WorktreeCreateAttempt, target: RuntimeTarget ): Promise { + if ( + request.startup?.deferredStartupOperationId !== undefined && + (target.kind !== 'local' || isWebClientLocation()) + ) { + throw new Error('deferred_startup_unavailable') + } const provisionedRoot = request.options?.provisionedRoot const create = async ( parentWorkspace: WorktreeCreateAttempt['parentWorkspace'] diff --git a/src/shared/deferred-startup-release.ts b/src/shared/deferred-startup-release.ts index 4116b7be3c0..eb1a322e310 100644 --- a/src/shared/deferred-startup-release.ts +++ b/src/shared/deferred-startup-release.ts @@ -4,3 +4,4 @@ export type StartupCommandReleaseResult = | 'retired' | 'identity-mismatch' | 'unavailable' +export type DeferredStartupStatus = 'pending' | 'accepted' | 'unverifiable' | 'retired' diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index d8f3cfc81c5..e358150228b 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -185,6 +185,7 @@ export type CreateWorktreeResult = { localBaseRefRefresh?: LocalBaseRefRefreshResult localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion startupTerminal?: { + deferredStartup?: { operationId: string; incarnationId: string | null } spawned: boolean handle?: string tabId?: string diff --git a/src/shared/worktree/launch-types.ts b/src/shared/worktree/launch-types.ts index dfd35bfa382..7eeda2cfc46 100644 --- a/src/shared/worktree/launch-types.ts +++ b/src/shared/worktree/launch-types.ts @@ -13,7 +13,15 @@ export type WorktreeSetupLaunch = { waitForAgentStartup?: boolean } +export type WorktreeStartupReleaseArgs = { + worktreeId: string + ptyId: string + expectedIncarnationId: string + operationId: string +} + export type WorktreeStartupLaunch = { + deferredStartupOperationId?: string /** False prepares the terminal without selecting the workspace. */ activate?: boolean command: string