Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/runtime/orchestration/orchestration-party-location.test.ts',
'src/main/runtime/orchestration/structured-worker-journal-page.test.ts',
'src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts',
'src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts',
'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts',
'src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts',
'src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts',
Expand Down
46 changes: 45 additions & 1 deletion src/main/agent-launch/agent-launch-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ function harness(options: {
/** Whether the surface reports that its typed line took the offered prompt. */
lineCarriesPrompt?: boolean
onSurfacePublished?: AgentLaunchExecution['onSurfacePublished']
/** A worktree-only factory, as orchestration workers and `worktree.create` pass. */
worktreeOnlyFactory?: boolean
}) {
const calls: string[] = []
const carried = (startupPrompt: string | undefined) =>
Expand All @@ -47,6 +49,10 @@ function harness(options: {
}
}
)
const createFolderWorkspace = vi.fn(async (_args: { create: Record<string, unknown> }) => {
calls.push('createFolderWorkspace')
return { worktreeId: 'folder:fw-new', connectionId: null }
})
const getStructuredAgentSessionCreateSupport = vi.fn(async () => {
calls.push('createSupport')
if (options.createSupportThrows) {
Expand Down Expand Up @@ -81,6 +87,7 @@ function harness(options: {
return {
calls,
createWorktree,
createFolderWorkspace,
createStructuredSession,
createTerminalAgent,
deliverStructuredPrompt,
Expand All @@ -96,7 +103,9 @@ function harness(options: {
deliverStructuredPrompt,
deliverTerminalPrompt
},
workspaces: { createWorktree },
workspaces: options.worktreeOnlyFactory
? { createWorktree }
: { createWorktree, createFolderWorkspace },
...(options.onSurfacePublished ? { onSurfacePublished: options.onSurfacePublished } : {})
})
}
Expand Down Expand Up @@ -220,6 +229,41 @@ describe('a structured launch that creates its own worktree', () => {
])
})

it('creates a folder workspace, then opens its structured session there', async () => {
const h = harness({})
const result = await h.run({
agent: 'claude',
target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } }
})

expect(h.calls).toEqual(['createFolderWorkspace', 'createSupport', 'createStructuredSession'])
expect(h.createFolderWorkspace).toHaveBeenCalledWith({ create: { projectGroupId: 'group-1' } })
expect(result.worktreeId).toBe('folder:fw-new')
expect(result.outcome.kind).toBe('structured')
})

it('starts a terminal agent in a new folder workspace, not agent-first', async () => {
const h = harness({ settings: null })
const result = await h.run({
agent: 'claude',
target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } }
})

expect(h.calls).toEqual(['createFolderWorkspace', 'createTerminalAgent'])
expect(result).toMatchObject({ worktreeId: 'folder:fw-new', outcome: { kind: 'terminal' } })
})

it('refuses a folder create from a factory that cannot make one, before anything is created', async () => {
const h = harness({ worktreeOnlyFactory: true })
await expect(
h.run({
agent: 'claude',
target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } }
})
).rejects.toThrow('agent_launch_workspace_factory_required')
expect(h.calls).toEqual([])
})

it('strips a stale startupAgent out of a migrated create payload', async () => {
const h = harness({})
await h.run({
Expand Down
23 changes: 9 additions & 14 deletions src/main/agent-launch/agent-launch-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,7 @@ import {
settledAtCreation,
settleLaunchPromptDisposal
} from './agent-launch-prompt-delivery'
import {
workspaceKindForWorktreeId,
type WorkspaceLaunchKind
} from '../../shared/workspace-launch-kind'
import { workspaceKindForLaunchTarget } from '../../shared/workspace-launch-kind'
import { isDefinitiveAgentSessionCreateRefusal } from '../../shared/agent-session-definitive-refusal'
import {
decideAgentLaunchMode,
Expand Down Expand Up @@ -86,7 +83,7 @@ export async function executeAgentLaunch(
decideAgentLaunchMode({
placement: {
agent: intent.agent,
workspaceKind: launchWorkspaceKind(intent.target),
workspaceKind: workspaceKindForLaunchTarget(intent.target),
...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}),
...(intent.cwd ? { cwd: intent.cwd } : {}),
...(intent.target.kind === 'existing' && intent.target.workspacePath
Expand Down Expand Up @@ -251,6 +248,13 @@ async function resolveWorkspace(
throw new Error('agent_launch_workspace_factory_required')
}
execution.onStage?.('worktree_create')
if (intent.target.kind === 'create-folder-workspace') {
if (!workspaces.createFolderWorkspace) {
throw new Error('agent_launch_workspace_factory_required')
}
const created = await workspaces.createFolderWorkspace({ create: intent.target.create })
return { ...created, startupTerminalHandle: undefined }
}
const created = await workspaces.createWorktree({
// A caller migrating from `worktree.create` passes its existing params; a stale `startupAgent`
// in there would re-create the agent-first path this executor exists to replace. The launch
Expand Down Expand Up @@ -383,12 +387,3 @@ function combineLaunchWarnings(
function existingWorktreeId(target: AgentLaunchTarget): string {
return target.kind === 'existing' ? target.worktree : ''
}

/**
* Read from the id rather than carried alongside it, so the kind cannot disagree with the workspace
* it describes. `worktree` here is never a caller's selector — the method resolved it to an id
* before building the intent — and a create always produces a git worktree.
*/
function launchWorkspaceKind(target: AgentLaunchTarget): WorkspaceLaunchKind {
return target.kind === 'existing' ? workspaceKindForWorktreeId(target.worktree) : 'git-worktree'
}
7 changes: 6 additions & 1 deletion src/main/agent-launch/agent-launch-opencode-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ function harness() {
},
deliverTerminalPrompt: deliverPrompt
},
workspaces: { createWorktree }
workspaces: {
createWorktree,
createFolderWorkspace: async () => {
throw new Error('folder_create_not_expected')
}
}
})
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/agent-launch/agent-launch-surface-factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,9 @@ export type AgentLaunchWorkspaceFactory = {
/** Reported by the create that built the startup command's typed line. */
promptRodeLaunchCommand?: boolean
}>
/** A folder workspace has no startup terminal: the launch starts its agent there afterwards.
* Only `agent.launch` creates folders; callers that only create worktrees omit it. */
createFolderWorkspace?(args: {
create: Readonly<Record<string, unknown>>
}): Promise<{ worktreeId: string; connectionId: string | null }>
}
3 changes: 3 additions & 0 deletions src/main/project-groups/folder-workspace-create-refusal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Marks a folder workspace create refused before anything was stored. Its message is the code the
// create has always thrown, so every caller reads the same refusal it did before.
export class FolderWorkspaceCreateRefusedError extends Error {}
20 changes: 14 additions & 6 deletions src/main/project-groups/folder-workspace-path-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,18 +212,26 @@ export async function getFolderWorkspacePathStatus(
)
}

export function assertFolderWorkspacePathUsable(status: FolderWorkspacePathStatus): void {
/** The code a folder workspace refuses this path with, or null when the path is usable. */
export function folderWorkspacePathRefusal(status: FolderWorkspacePathStatus): string | null {
if (status.exists) {
return
return null
}
if (status.reason === 'missing') {
throw new Error(`folder_workspace_path_missing:${status.path}`)
return `folder_workspace_path_missing:${status.path}`
}
if (status.reason === 'not-directory') {
throw new Error(`folder_workspace_path_not_directory:${status.path}`)
return `folder_workspace_path_not_directory:${status.path}`
}
if (status.reason === 'ambiguous-connection') {
throw new Error(`folder_workspace_connection_ambiguous:${status.path}`)
return `folder_workspace_connection_ambiguous:${status.path}`
}
return `folder_workspace_path_unavailable:${status.path}`
}

export function assertFolderWorkspacePathUsable(status: FolderWorkspacePathStatus): void {
const refusal = folderWorkspacePathRefusal(status)
if (refusal) {
throw new Error(refusal)
}
throw new Error(`folder_workspace_path_unavailable:${status.path}`)
}
2 changes: 2 additions & 0 deletions src/main/runtime/rpc/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { WORKTREE_CREATE_COLLISION_CODE } from '../../../shared/new-workspace/wo
import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from '../../../shared/agent-launch-pane-already-live'
import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../shared/agent-launch-session-already-exists'
import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../shared/agent-launch-tab-closed'
import { AGENT_LAUNCH_TARGET_FORBIDDEN_CODE } from '../../../shared/agent-launch-target-forbidden'

export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
return {
Expand Down Expand Up @@ -68,6 +69,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE,
AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE,
AGENT_LAUNCH_TAB_CLOSED_CODE,
AGENT_LAUNCH_TARGET_FORBIDDEN_CODE,
'agent_launch_replay_unsupported',
'runtime_unavailable',
'selector_not_found',
Expand Down
23 changes: 21 additions & 2 deletions src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* A launch from a paired client moves that client's view to the new tab and nobody else's: the view
* intent belongs to the connection that asked. In-process callers and workspace-creating launches
* intent belongs to the connection that asked. In-process callers and worktree-creating launches
* keep today's behaviour.
*/

Expand Down Expand Up @@ -45,6 +45,10 @@ const CREATE_LAUNCH = {
agent: 'claude',
target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } }
}
const FOLDER_LAUNCH = {
agent: 'claude',
target: { kind: 'create-folder-workspace', create: { projectGroupId: 'group-1' } }
}
const CALLER = 'device-1'
const REVEAL_WARNING =
'Terminal term_1 is running, but Orca could not make it discoverable. Run `orca terminal focus --terminal term_1` to reveal and focus it.'
Expand Down Expand Up @@ -150,6 +154,21 @@ describe('a paired client launching into an existing workspace', () => {
expect(result.warning).toBeUndefined()
})

// A folder workspace has no setup for a host activation to run, so its create moves only the caller.
it('selects the chat in a folder workspace it creates without activating it on the host', async () => {
const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE })

// A desktop client of a remote server; a phone may not create folder workspaces at all.
await launch(FOLDER_LAUNCH, runtime, { ...CAPABLE_CLIENT, clientKind: 'runtime' })

expect(chatActivation()).toBe(false)
expect(runtime.selectCreatedMobileSessionTabForClient).toHaveBeenCalledExactlyOnceWith(
'folder:fw-new',
{ sessionId: 'sess-1' },
CALLER
)
})

it('selects nothing when the runtime reported no pane for the terminal', async () => {
const runtime = selectionRuntime({ settings: {} })

Expand Down Expand Up @@ -190,7 +209,7 @@ describe('launches that keep the host-wide behaviour', () => {
expect(runtime.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled()
})

it('a workspace-creating launch from a paired client keeps the create navigation', async () => {
it('a worktree-creating launch from a paired client keeps the create navigation', async () => {
const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE })

await launch(CREATE_LAUNCH, runtime)
Expand Down
12 changes: 7 additions & 5 deletions src/main/runtime/rpc/methods/agent-launch-caller-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
* Which view a launch moves: the requesting connection's, never the host's or another client's.
*
* A paired client (a phone, or a desktop client of a remote server) that launches into an existing
* workspace gets the new tab as its own selection, recorded the way `session.tabs.createTerminal`
* selects the tab it creates for its caller. In-process callers keep today's behaviour, and their
* launch that creates its workspace keeps `worktree.create`'s navigation. A paired client's create
* never activates the host window: its setup and default tabs are provisioned in the background.
* workspace, or into a folder workspace it creates, gets the new tab as its own selection, recorded
* the way `session.tabs.createTerminal` selects the tab it creates for its caller. In-process
* callers keep today's behaviour, and their launch that creates a worktree keeps
* `worktree.create`'s navigation. A paired client's worktree create never activates the host
* window: its setup and default tabs are provisioned in the background. A folder workspace has
* neither, and its create activates nothing, so it is selected for its caller like an existing one.
*/

import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent'
Expand All @@ -19,7 +21,7 @@ export function agentLaunchCallerNavigationId(
target: AgentLaunchTarget,
context: Pick<RpcContext, 'caller'>
): string | null {
return target.kind === 'existing' && context.caller?.kind === 'paired-device'
return target.kind !== 'create-worktree' && context.caller?.kind === 'paired-device'
? context.caller.deviceId
: null
}
Expand Down
24 changes: 22 additions & 2 deletions src/main/runtime/rpc/methods/agent-launch-failure-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import {
AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE
} from '../../../../shared/agent-launch-session-already-exists'
import type { TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started'
import { FolderWorkspaceCreateRefusedError } from '../../../project-groups/folder-workspace-create-refusal'

/** Long enough for every code this path raises, with room for one a later guard adds. */
const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128
/** A path as long as Linux's PATH_MAX, after its code. */
const FOLDER_CREATE_REFUSAL_MAX_LENGTH = 4096 + LAUNCH_FAILURE_CODE_MAX_LENGTH

/**
* This path raises its refusals as the thrown code, the way the method's own guards do — and the
Expand All @@ -33,10 +36,24 @@ export function agentLaunchFailureCode(error: unknown): string {
return code.length > 0 ? code.slice(0, LAUNCH_FAILURE_CODE_MAX_LENGTH) : 'agent_launch_failed'
}

/**
* A refused folder create names its folder, and the user is shown that path, so a replay carries all
* of it or none: a path cut short names a different folder. Any path a filesystem stat accepts
* fits; past that, only the code before the path is kept.
*/
function folderCreateRefusalCode(error: FolderWorkspaceCreateRefusedError): string {
const { message } = error
const pathStart = message.indexOf(':')
return message.length <= FOLDER_CREATE_REFUSAL_MAX_LENGTH || pathStart === -1
? message
: message.slice(0, pathStart)
}

/**
* Only a typed refusal raised before anything was created proves the claimed launch had no effects.
* A live reserved pane or an existing reserved session proves it only for an existing workspace; on
* create-worktree the workspace already exists by the time the surface is refused.
* A refused create proves it for any target, since the create is the launch's first effect. A live
* reserved pane or an existing reserved session proves it only for an existing workspace; on a
* create target the workspace already exists by the time the surface is refused.
*/
export function launchFailureWithoutEffectsCode(
error: unknown,
Expand All @@ -46,6 +63,9 @@ export function launchFailureWithoutEffectsCode(
if (error instanceof WorktreeCreateCollisionError) {
return WORKTREE_CREATE_COLLISION_CODE
}
if (error instanceof FolderWorkspaceCreateRefusedError) {
return folderCreateRefusalCode(error)
}
if (error instanceof AgentLaunchPaneAlreadyLiveError && targetKind === 'existing') {
return AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE
}
Expand Down
Loading
Loading