feat(worktree): prepare agent shells in retained composer drafts

This commit is contained in:
Neil
2026-09-05 04:45:29 -07:00
parent b268adef18
commit f03dc12b0d
41 changed files with 1297 additions and 55 deletions
+19 -5
View File
@@ -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."
@@ -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.
@@ -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
@@ -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)
+2 -2
View File
@@ -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<PtySpawnResult, 'agentSessionEnsure'> | Record<string, never> =>
result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}
const incarnationResult = (): Pick<PtySpawnResult, 'incarnationId'> | Record<string, never> =>
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
@@ -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 } : {}),
+9 -2
View File
@@ -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'
+5
View File
@@ -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
+5
View File
@@ -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
@@ -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
+8 -3
View File
@@ -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<PtySpawnResult> {
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<PtySpawn
// seed but the list/read records still live main-side.
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
// Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one.
if (!ctx.stablePaneOwner) {
if (!ctx.stablePaneOwner && !startupDeferred) {
ctx.deps.runtime?.noteTerminalSpawnCommand?.(
ctx.result.id,
typeof ctx.launchCommand === 'string' ? ctx.launchCommand : null
)
}
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) {
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner && !startupDeferred) {
markClaudePtySpawned(ctx.result.id)
}
// Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit.
@@ -200,7 +205,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
})
}
// Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it).
if (args.telemetry && !ctx.stablePaneOwner) {
if (args.telemetry && !ctx.stablePaneOwner && !startupDeferred) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
+2 -1
View File
@@ -1,7 +1,8 @@
import { PtyOwnershipRegistry } from './pty-ownership-registry'
import { isPtyIncarnationId } from '../../../../shared/pty-incarnation'
// Why: post-spawn write/resize/kill calls carry only the PTY ID; map it to its connectionId so ops route to the right provider.
export const ptyOwnership = new Map<string, string | null>()
export const ptyOwnership = new PtyOwnershipRegistry()
export const ptyIncarnationById = new Map<string, string>()
export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean {
@@ -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<string, string | null> {
private readonly deferredStartups = new Map<string, DeferredStartup>()
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()
}
}
}
@@ -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)
+14 -1
View File
@@ -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
}
+2 -28
View File
@@ -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
@@ -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<StartupCommandReleaseResult>>()
}))
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()
})
})
@@ -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()
}
+22 -1
View File
@@ -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<CreateWorktreeResult> {
if (args.startup?.deferredStartupOperationId !== undefined) {
throw new Error('deferred_startup_unavailable')
}
const timing = createWorktreeCreateTimingRecorder()
const provider = requireSshGitProvider(repo.connectionId!)
const fsProvider = getSshFilesystemProvider(repo.connectionId!)
@@ -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<Record<string, unknown>>()),
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<string, unknown>
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) => {
const actual = await importOriginal<typeof WorktreeLogic>()
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()
})
})
+4
View File
@@ -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)
@@ -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'
@@ -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<string, (...args: unknown[]) => unknown>(),
supports: vi.fn(async () => true),
release: vi.fn(async () => 'accepted'),
ownership: new Map<string, string | null>()
}))
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()
})
})
@@ -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'
})
}
+2
View File
@@ -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. */
+4
View File
@@ -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<boolean>
releaseStartup?: (args: WorktreeStartupReleaseArgs) => Promise<StartupCommandReleaseResult>
list: (args: { repoId: string }) => Promise<Worktree[]>
/** 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
+3
View File
@@ -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),
+7
View File
@@ -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.",
@@ -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<WorktreeCreationRequest['startup']> {
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
}
})
}
@@ -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 }))
@@ -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(
@@ -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
}
@@ -14,6 +14,7 @@ export async function completeWorktreeCreation(args: {
primaryTabId: string | null
startupTerminalTabId?: string
backendSpawned: boolean
deliverBackendStartup?: boolean
focusOnCompletion: boolean
}): Promise<void> {
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
})
}
@@ -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<string, PendingWorktreeCreation>,
updatePendingWorktreeCreation: vi.fn((id: string, patch: Partial<PendingWorktreeCreation>) => {
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<void>((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<void>((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<string>((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()
})
})
@@ -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<CreateWorktreeResult>
): Promise<void> {
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
})
}
@@ -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<boolean> {
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
}
@@ -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
@@ -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<CreateAttemptOutcome> {
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']
+1
View File
@@ -4,3 +4,4 @@ export type StartupCommandReleaseResult =
| 'retired'
| 'identity-mismatch'
| 'unavailable'
export type DeferredStartupStatus = 'pending' | 'accepted' | 'unverifiable' | 'retired'
+1
View File
@@ -185,6 +185,7 @@ export type CreateWorktreeResult = {
localBaseRefRefresh?: LocalBaseRefRefreshResult
localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion
startupTerminal?: {
deferredStartup?: { operationId: string; incarnationId: string | null }
spawned: boolean
handle?: string
tabId?: string
+8
View File
@@ -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