From 7948ef2ce85667d9b4e5a6529329338cd06c415d Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Thu, 14 May 2026 20:05:03 -0700 Subject: [PATCH] Add workspace lineage grouping Co-authored-by: Orca --- .gitignore | 1 + 00-design-review-context.md | 73 ++ src/cli/format.ts | 12 + src/cli/handlers/orchestration.ts | 8 +- src/cli/handlers/worktree.ts | 73 +- src/cli/help.ts | 4 +- src/cli/index.test.ts | 45 +- src/cli/specs/core.ts | 16 +- src/main/ipc/worktree-logic.ts | 1 + src/main/ipc/worktree-remote.ts | 9 + src/main/ipc/worktrees.test.ts | 144 +++- src/main/ipc/worktrees.ts | 66 ++ src/main/persistence.ts | 31 + src/main/runtime/orca-runtime.test.ts | 793 +++++++++++++++++- src/main/runtime/orca-runtime.ts | 484 ++++++++++- src/main/runtime/orchestration/db.test.ts | 11 + src/main/runtime/orchestration/db.ts | 34 +- src/main/runtime/orchestration/types.ts | 1 + src/main/runtime/rpc/errors.ts | 14 + .../runtime/rpc/methods/orchestration.test.ts | 10 + src/main/runtime/rpc/methods/orchestration.ts | 6 +- src/main/runtime/rpc/methods/worktree.test.ts | 57 ++ src/main/runtime/rpc/methods/worktree.ts | 92 +- src/preload/api-types.ts | 7 + src/preload/index.ts | 9 + src/renderer/src/App.tsx | 5 + .../src/components/sidebar/SidebarHeader.tsx | 11 + .../src/components/sidebar/WorktreeCard.tsx | 26 +- .../sidebar/WorktreeContextMenu.tsx | 85 +- .../src/components/sidebar/WorktreeList.tsx | 210 ++++- .../sidebar/worktree-list-groups.test.ts | 228 ++++- .../sidebar/worktree-list-groups.ts | 234 +++++- src/renderer/src/hooks/useIpcEvents.ts | 1 + src/renderer/src/lib/startup-ui-hydration.ts | 1 + src/renderer/src/store/slices/ui.ts | 8 +- .../src/store/slices/worktree-helpers.ts | 7 + src/renderer/src/store/slices/worktrees.ts | 33 + src/shared/constants.ts | 2 + src/shared/runtime-types.ts | 10 +- src/shared/types.ts | 45 + 40 files changed, 2800 insertions(+), 107 deletions(-) create mode 100644 00-design-review-context.md create mode 100644 src/main/runtime/rpc/methods/worktree.test.ts diff --git a/.gitignore b/.gitignore index 28e3868ff1c..3ff059cfdda 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ tmp/ design-docs/ .context/ .atl/ +/agent-hooks/ # Local-only design/planning docs (not checked in) docs/*.md diff --git a/00-design-review-context.md b/00-design-review-context.md new file mode 100644 index 00000000000..82c442b04e9 --- /dev/null +++ b/00-design-review-context.md @@ -0,0 +1,73 @@ +# Design Review Context + +## Document Info +- Path: docs/orchestration-workspace-lineage.md +- Type: Technical Spec / Product Design +- Review started: 2026-05-14 15:36:41 PDT + +## Design Direction +- Direction confirmed: confirmed +- Chosen approach: first-class persisted workspace lineage with runtime-validated capture source/confidence, followed by opt-in sidebar Parent grouping +- Alternatives considered: session-scoped lineage only; orchestration-only lineage; storing parent fields directly on WorktreeMeta +- Key UX decisions: Parent grouping remains opt-in; pinned workspaces keep precedence with parent badges; inferred relationships are visible in CLI output and JSON; hidden/missing parent states remain recoverable + +## Iteration State +Current iteration: 3 +Last completed phase: Iteration 3 Verification +Issues addressed this iteration: none; verification found no remaining P0/P1 findings + +## Addressed Issues (Do Not Re-report) + + + +Phase 0.5 | P1 | UX | Hidden confidence state for inferred parent | Added capture source/confidence to lineage and CLI output +Phase 0.5 | P1 | Architecture | Runtime capture source of truth unclear | Added Capture Source of Truth section and runtime validation rules for caller context +Phase 0.5 | P2 | UX | Parent grouping interactions under-specified | Added interaction state matrix for grouping, reveal, collapse, and repair flows +Phase 0.5 | P2 | Architecture | Row model/reveal risk under-specified | Added flat row model contract and sidebar grouping test requirements +Phase 0.5 | P2 | Architecture | Orchestration enrichment bridge incomplete | Added narrow runtime orchestration bridge and centralized validation guidance +Iteration 1 | P1 | CLI | CLI JSON shape/stdout contract ambiguity | Specified existing RPC envelope, single JSON stdout object, and stderr-only human diagnostics +Iteration 1 | P1 | CLI | Missing JSON warning/error contracts | Added `result.warnings[]`, stable lineage warning codes, and structured `ok: false` error example with `nextSteps` +Iteration 1 | P2 | Data integrity | Path-derived worktree ID reuse can stale-attach lineage | Added immutable workspace instance IDs and stale-instance handling +Iteration 1 | P2 | UX | Hidden-parent rendering was ambiguous | Made Parent grouping use `Hidden parent` deterministically and non-Parent modes use badges +Iteration 1 | P2 | Operational | Lineage failure observability under-specified | Added structured diagnostics with reason code, request ID, runtime ID, and candidate source types +Iteration 1 | P2 | UX | Repair action semantics under-specified | Renamed action to `Group under active workspace` and defined selection, disabled, multi-select, and SSH validation behavior +Iteration 2 | P1 | UX | Hidden parent behavior conflicted with filter contract | Made Parent grouping always render the real parent group when the parent exists and any child matches filters +Iteration 2 | P2 | API contract | Stale-instance warning placement under-specified | Clarified `LINEAGE_PARENT_INSTANCE_STALE` is a hydration/read-path diagnostic, not a create warning + +## Skipped Issues (Accepted Risks) + + + + +Phase 0.5 | P3 | UX | Below fix threshold after adding CLI/JSON confidence signals | Cognitive load from four grouping modes plus pinning exception + +## Invalidated Findings (Do Not Re-report) + + + + +[Initially empty - populated after each validation phase] + +## Findings History + + +### Phase 0.5 +- Direction challenge confirmed the proposed direction with refinement. +- P0: 0 | P1: 2 | P2: 3 | P3: 1 +- Addressed: 5 | Skipped: 1 +- Key changes: capture confidence/source, runtime validation source-of-truth, interaction states, row model contract, orchestration bridge, capture-quality rollout gate + +### Iteration 1 +- P0: 0 | P1: 2 | P2: 4 | P3: 0 +- Addressed: 6 | Skipped: 0 +- Key changes: JSON envelope compatibility, structured warning/error contracts, instance IDs for stale lineage protection, deterministic hidden-parent rendering, diagnostics, repair-action semantics + +### Iteration 2 +- P0: 0 | P1: 1 | P2: 1 | P3: 0 +- Addressed: 2 | Skipped: 0 +- Key changes: parent group/filter precedence clarified; stale-instance diagnostics scoped to hydration/read path + +### Iteration 3 +- P0: 0 | P1: 0 | P2: 0 | P3: 0 +- Addressed: 0 | Skipped: 0 +- Key changes: final verification only; primary and secondary reviewers both reported no P0/P1 findings diff --git a/src/cli/format.ts b/src/cli/format.ts index b8cce4771af..05d8da1ac4c 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -60,6 +60,18 @@ export function formatCliError(error: unknown): string { ) { return `${message}\nOrca is not running. Run 'orca open' first.` } + if (error instanceof RuntimeRpcFailureError) { + const data = error.response.error.data + const nextSteps = + data && typeof data === 'object' && Array.isArray((data as { nextSteps?: unknown }).nextSteps) + ? (data as { nextSteps: unknown[] }).nextSteps.filter( + (step): step is string => typeof step === 'string' + ) + : [] + if (nextSteps.length > 0) { + return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}` + } + } return message } diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 4520d33888a..5398c55aadf 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -207,12 +207,18 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration task-create': async ({ flags, client, json }) => { + const callerTerminalHandle = + typeof process.env.ORCA_TERMINAL_HANDLE === 'string' && + process.env.ORCA_TERMINAL_HANDLE.length > 0 + ? process.env.ORCA_TERMINAL_HANDLE + : undefined const result = await client.call<{ task: { id: string; status: string } }>( 'orchestration.taskCreate', { spec: getRequiredStringFlag(flags, 'spec'), deps: getOptionalStringFlag(flags, 'deps'), - parent: getOptionalStringFlag(flags, 'parent') + parent: getOptionalStringFlag(flags, 'parent'), + callerTerminalHandle } ) printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`) diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 5dfe9873d60..d3238f7e1bf 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -1,7 +1,8 @@ import type { RuntimeWorktreeListResult, RuntimeWorktreePsResult, - RuntimeWorktreeRecord + RuntimeWorktreeRecord, + RuntimeWorktreeCreateResult } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format' @@ -24,6 +25,30 @@ function printHookWarning(result: HookWarningResult, json: boolean): void { } } +function printLineageSummary(result: RuntimeWorktreeCreateResult, json: boolean): void { + if (json) { + return + } + for (const warning of result.warnings ?? []) { + console.error(`warning: ${warning.message}`) + } + if (result.lineage) { + const source = + result.lineage.capture.source === 'terminal-context' + ? 'terminal' + : result.lineage.capture.source === 'orchestration-context' + ? 'orchestration' + : result.lineage.capture.source === 'explicit-cli-flag' + ? 'explicit flag' + : 'manual action' + console.error( + `parent: ${result.lineage.parentWorktreeId} (${result.lineage.capture.confidence} from ${source})` + ) + } else { + console.error('parent: none') + } +} + export const WORKTREE_HANDLERS: Record = { 'worktree ps': async ({ flags, client, json }) => { const result = await client.call('worktree.ps', { @@ -50,20 +75,38 @@ export const WORKTREE_HANDLERS: Record = { }) printResult(result, json, formatWorktreeShow) }, - 'worktree create': async ({ flags, client, json }) => { - const result = await client.call<{ worktree: RuntimeWorktreeRecord; warning?: string }>( - 'worktree.create', - { - repo: getRequiredStringFlag(flags, 'repo'), - name: getRequiredStringFlag(flags, 'name'), - baseBranch: getOptionalStringFlag(flags, 'base-branch'), - linkedIssue: getOptionalNumberFlag(flags, 'issue'), - comment: getOptionalStringFlag(flags, 'comment'), - runHooks: flags.get('run-hooks') === true, - activate: flags.get('activate') === true || flags.get('run-hooks') === true + 'worktree create': async ({ flags, client, cwd, json }) => { + const callerTerminalHandle = + typeof process.env.ORCA_TERMINAL_HANDLE === 'string' && + process.env.ORCA_TERMINAL_HANDLE.length > 0 + ? process.env.ORCA_TERMINAL_HANDLE + : undefined + const explicitParentWorktree = getOptionalStringFlag(flags, 'parent-worktree') + const noParent = flags.get('no-parent') === true + let parentWorktree = explicitParentWorktree + if (!parentWorktree && !noParent && !callerTerminalHandle) { + try { + // Why: agent shells can lose ORCA_TERMINAL_HANDLE while still running + // inside an Orca worktree. Cwd keeps CLI-created children nestable. + parentWorktree = await resolveCurrentWorktreeSelector(cwd, client) + } catch { + parentWorktree = undefined } - ) + } + const result = await client.call('worktree.create', { + repo: getRequiredStringFlag(flags, 'repo'), + name: getRequiredStringFlag(flags, 'name'), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + linkedIssue: getOptionalNumberFlag(flags, 'issue'), + comment: getOptionalStringFlag(flags, 'comment'), + runHooks: flags.get('run-hooks') === true, + activate: flags.get('activate') === true || flags.get('run-hooks') === true, + parentWorktree, + noParent, + callerTerminalHandle + }) printHookWarning(result.result, json) + printLineageSummary(result.result, json) printResult(result, json, formatWorktreeShow) }, 'worktree set': async ({ flags, client, cwd, json }) => { @@ -71,7 +114,9 @@ export const WORKTREE_HANDLERS: Record = { worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client), displayName: getOptionalStringFlag(flags, 'display-name'), linkedIssue: getOptionalNullableNumberFlag(flags, 'issue'), - comment: getOptionalStringFlag(flags, 'comment') + comment: getOptionalStringFlag(flags, 'comment'), + parentWorktree: getOptionalStringFlag(flags, 'parent-worktree'), + noParent: flags.get('no-parent') === true }) printResult(result, json, formatWorktreeShow) }, diff --git a/src/cli/help.ts b/src/cli/help.ts index 411ddd98715..1b87b2d6570 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -140,10 +140,10 @@ Common Commands: orca open [--json] orca status [--json] orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--activate] [--json] + orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] orca worktree show --worktree [--json] orca worktree current [--json] - orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json] + orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--parent-worktree |--no-parent] [--json] orca worktree rm --worktree [--force] [--run-hooks] [--json] orca worktree ps [--limit ] [--json] orca terminal list [--worktree ] [--limit ] [--json] diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 668354a2607..cddf5d9f7db 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -61,6 +61,7 @@ describe('orca cli worktree awareness', () => { beforeEach(() => { callMock.mockReset() + delete process.env.ORCA_TERMINAL_HANDLE }) afterEach(() => { @@ -142,13 +143,16 @@ describe('orca cli worktree awareness', () => { worktree: `path:${path.resolve('/tmp/repo/feature')}`, displayName: undefined, linkedIssue: undefined, - comment: 'hello' + comment: 'hello', + parentWorktree: undefined, + noParent: false }) }) it('passes explicit activation through worktree.create', async () => { queueFixtures( callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]), okFixture('req_create', { worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1') }) @@ -160,20 +164,24 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('worktree.create', { + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'feature', baseBranch: undefined, linkedIssue: undefined, comment: undefined, runHooks: false, - activate: true + activate: true, + parentWorktree: `path:${path.resolve('/tmp/repo')}`, + noParent: false, + callerTerminalHandle: undefined }) }) it('opts into setup and activation when worktree.create runs hooks', async () => { queueFixtures( callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]), okFixture('req_create', { worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1') }) @@ -185,14 +193,17 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('worktree.create', { + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { repo: 'id:repo-1', name: 'feature', baseBranch: undefined, linkedIssue: undefined, comment: undefined, runHooks: true, - activate: true + activate: true, + parentWorktree: `path:${path.resolve('/tmp/repo')}`, + noParent: false, + callerTerminalHandle: undefined }) }) @@ -308,6 +319,30 @@ describe('orca cli worktree awareness', () => { errSpy.mockRestore() }) + it('passes the caller terminal handle through orchestration task-create', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_creator' + callMock.mockResolvedValueOnce({ + id: 'req_task_create', + ok: true, + result: { + task: { id: 'task_1', status: 'ready' } + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['orchestration', 'task-create', '--spec', 'spawn child workspace'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledWith('orchestration.taskCreate', { + spec: 'spawn child workspace', + deps: undefined, + parent: undefined, + callerTerminalHandle: 'term_creator' + }) + }) + it('passes dev mode to injected orchestration dispatches', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_sender' process.env.ORCA_USER_DATA_PATH = '/tmp/orca-dev' diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index b85424e8ac0..8991f8a5f46 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -72,7 +72,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--activate] [--json]', + 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'repo', @@ -80,6 +80,8 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'base-branch', 'issue', 'comment', + 'parent-worktree', + 'no-parent', 'run-hooks', 'activate' ], @@ -94,8 +96,16 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'set'], summary: 'Update Orca metadata for a worktree', usage: - 'orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'display-name', 'issue', 'comment'] + 'orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--parent-worktree |--no-parent] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'worktree', + 'display-name', + 'issue', + 'comment', + 'parent-worktree', + 'no-parent' + ] }, { path: ['worktree', 'rm'], diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index fe2c086fc61..2a401618778 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -175,6 +175,7 @@ export function mergeWorktree( const branchShort = git.branch.replace(/^refs\/heads\//, '') return { id: `${repoId}::${git.path}`, + ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId, path: git.path, head: git.head, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 948297d004b..7198e23f42d 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -10,6 +10,7 @@ import type { BrowserWindow } from 'electron' import { join } from 'path' import { existsSync } from 'fs' +import { randomUUID } from 'crypto' import type { Store } from '../persistence' import type { CreateWorktreeArgs, @@ -311,6 +312,10 @@ export async function createRemoteWorktree( const worktreeId = `${repo.id}::${created.path}` const now = Date.now() const metaUpdates: Partial = { + // Why: path-derived worktree IDs can be reused after external deletion. + // Fresh creations must rotate instance identity so stale lineage cannot + // attach to the new occupant of the same path. + instanceId: randomUUID(), lastActivityAt: now, // Why: grants the new worktree a short grace window at the top of the // Recent sort. During worktree creation (git fetch + add can take several @@ -594,6 +599,10 @@ export async function createLocalWorktree( const worktreeId = `${repo.id}::${created.path}` const now = Date.now() const metaUpdates: Partial = { + // Why: path-derived worktree IDs can be reused after external deletion. + // Fresh creations must rotate instance identity so stale lineage cannot + // attach to the new occupant of the same path. + instanceId: randomUUID(), // Stamp activity so the worktree sorts into its final position // immediately — prevents scroll-to-reveal racing with a later // bumpWorktreeActivity that would re-sort the list. diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 25ff76c4dba..1a8f43ede9a 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -154,7 +154,9 @@ describe('registerWorktreeHandlers', () => { getSettings: vi.fn(), getWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), - removeWorktreeMeta: vi.fn() + removeWorktreeMeta: vi.fn(), + getAllWorktreeLineage: vi.fn(), + removeWorktreeLineage: vi.fn() } let runtimeStub: { resolveRemoteTrackingBase: ReturnType @@ -204,6 +206,8 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta, store.setWorktreeMeta, store.removeWorktreeMeta, + store.getAllWorktreeLineage, + store.removeWorktreeLineage, killAllProcessesForWorktreeMock, getLocalPtyProviderMock ]) { @@ -242,6 +246,7 @@ describe('registerWorktreeHandlers', () => { }) store.getWorktreeMeta.mockReturnValue(undefined) store.setWorktreeMeta.mockReturnValue({}) + store.getAllWorktreeLineage.mockReturnValue({}) getGitUsernameMock.mockReturnValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') getDefaultRemoteMock.mockResolvedValue('origin') @@ -586,6 +591,104 @@ describe('registerWorktreeHandlers', () => { }) }) + it('prunes stale child lineage after a successful SSH worktree scan proves the child is missing', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/live', + head: 'abc123', + branch: 'refs/heads/live', + isBare: false, + isMainWorktree: false + }, + { + path: '/remote/live-child', + head: 'def456', + branch: 'refs/heads/live-child', + isBare: false, + isMainWorktree: false + } + ]) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-ssh::/remote/missing-child': { + parentWorktreeId: 'repo-ssh::/remote/live' + }, + 'repo-ssh::/remote/live-child': { + parentWorktreeId: 'repo-ssh::/remote/missing-parent', + parentWorktreeInstanceId: 'old-parent-instance' + }, + 'repo-ssh::/remote/live': { + parentWorktreeId: 'other-repo::/elsewhere' + } + }) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => + worktreeId === 'repo-ssh::/remote/missing-parent' + ? { instanceId: 'old-parent-instance' } + : undefined + ) + + await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + + expect(store.removeWorktreeLineage).toHaveBeenCalledWith('repo-ssh::/remote/missing-child') + expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith('repo-ssh::/remote/live-child') + expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith('repo-ssh::/remote/live') + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-ssh::/remote/missing-parent', + expect.objectContaining({ instanceId: expect.any(String) }) + ) + }) + + it('does not repeatedly rotate already-invalid missing parent metadata', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/live-child', + head: 'def456', + branch: 'refs/heads/live-child', + isBare: false, + isMainWorktree: false + } + ]) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-ssh::/remote/live-child': { + parentWorktreeId: 'repo-ssh::/remote/missing-parent', + parentWorktreeInstanceId: 'old-parent-instance' + } + }) + store.getWorktreeMeta.mockReturnValue({ instanceId: 'rotated-parent-instance' }) + + await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + + expect(store.setWorktreeMeta).not.toHaveBeenCalledWith( + 'repo-ssh::/remote/missing-parent', + expect.objectContaining({ instanceId: expect.any(String) }) + ) + }) + it('does not await a cold fetch when the remote-tracking base exists locally', async () => { const remoteBase = { remote: 'origin', @@ -777,6 +880,7 @@ describe('registerWorktreeHandlers', () => { comment: '', linkedIssue: null, linkedPR: null, + instanceId: 'existing-instance', isArchived: false, isUnread: false, isPinned: false, @@ -793,6 +897,44 @@ describe('registerWorktreeHandlers', () => { expect(listed[0].lastActivityAt).toBe(42) }) + it('backfills instanceId on discovery for persisted metadata from older profiles', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/existing-wt', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue({ + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 42 + }) + store.setWorktreeMeta.mockReturnValue({ + instanceId: 'new-instance', + lastActivityAt: 42 + }) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + instanceId?: string + }[] + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/existing-wt', + expect.objectContaining({ instanceId: expect.any(String) }) + ) + expect(listed[0].instanceId).toBe('new-instance') + }) + it('stamps lastActivityAt on first discovery for folder-mode repos', async () => { // Why: folder repos produce a synthetic worktree that flows through the // same list path. Without the stamp, adding a folder puts its card at the diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index eef52f3d289..a736ae61ad7 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' import { rm } from 'fs/promises' +import { randomUUID } from 'crypto' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' import { deleteWorktreeHistoryDir } from '../terminal-history' @@ -61,6 +62,12 @@ import { classifyWorkspaceCreateError } from './workspace-create-error-classifie function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta { const existing = store.getWorktreeMeta(worktreeId) if (existing) { + if (!existing.instanceId) { + // Why: profiles created before lineage shipped already have WorktreeMeta + // rows. Backfill on authoritative discovery so upgraded workspaces can + // immediately participate in instance-validated lineage. + return store.setWorktreeMeta(worktreeId, { instanceId: randomUUID() }) + } return existing } return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() }) @@ -98,6 +105,40 @@ function rememberLocalWorktreeRoots( ]) } +function pruneLineageForMissingRepoWorktrees( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[] +): void { + if ( + typeof store.getAllWorktreeLineage !== 'function' || + typeof store.removeWorktreeLineage !== 'function' + ) { + return + } + const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) + const repoPrefix = `${repo.id}::` + for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { + if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { + // Why: path-derived IDs can disappear and later be reused by a different + // checkout. Once a successful scan proves the child is gone, drop its + // lineage so a future same-path worktree cannot inherit it. Missing + // parents stay readable so the UI can show the repairable "Missing + // parent" state. + store.removeWorktreeLineage(childId) + } + if (lineage.parentWorktreeId.startsWith(repoPrefix) && !liveIds.has(lineage.parentWorktreeId)) { + const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) + if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { + // Why: keep the child lineage so the UI can show "Missing parent", but + // rotate the absent parent's stale identity once. If a different + // checkout later reuses that path, the old lineage stays invalid. + store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) + } + } + } +} + export function registerWorktreeHandlers( mainWindow: BrowserWindow, store: Store, @@ -111,6 +152,8 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:resolvePrBase') ipcMain.removeHandler('worktrees:remove') ipcMain.removeHandler('worktrees:updateMeta') + ipcMain.removeHandler('worktrees:listLineage') + ipcMain.removeHandler('worktrees:updateLineage') ipcMain.removeHandler('worktrees:persistSortOrder') ipcMain.removeHandler('hooks:check') ipcMain.removeHandler('hooks:createIssueCommandRunner') @@ -144,6 +187,7 @@ export function registerWorktreeHandlers( gitWorktrees = await listRepoWorktrees(repo) } rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` @@ -204,6 +248,7 @@ export function registerWorktreeHandlers( gitWorktrees = await listRepoWorktrees(repo) } rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` @@ -620,6 +665,27 @@ export function registerWorktreeHandlers( } ) + ipcMain.handle('worktrees:listLineage', async () => { + await runtime.hydrateInferredWorktreeLineage() + return store.getAllWorktreeLineage() + }) + + ipcMain.handle( + 'worktrees:updateLineage', + async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { + await runtime.updateManagedWorktreeMeta(args.worktreeId, { + lineage: + args.noParent === true + ? { noParent: true } + : args.parentWorktreeId + ? { parentWorktree: `id:${args.parentWorktreeId}` } + : undefined + }) + notifyWorktreesChanged(mainWindow, parseWorktreeId(args.worktreeId).repoId) + return store.getWorktreeLineage(args.worktreeId) ?? null + } + ) + // Why: the renderer continuously snapshots the computed sidebar order into // sortOrder so that it can be restored on cold start (when ephemeral signals // like running jobs and live terminals are gone). A single batch call avoids diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2d09b3c9086..bd2b92663bd 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -33,6 +33,7 @@ import type { Repo, SparsePreset, WorktreeMeta, + WorktreeLineage, GlobalSettings, OnboardingChecklistState, OnboardingOutcome, @@ -411,6 +412,7 @@ export class Store { result = { ...defaults, ...parsed, + worktreeLineageById: parsed.worktreeLineageById ?? {}, settings: { ...defaults.settings, ...parsed.settings, @@ -863,6 +865,11 @@ export class Store { delete this.state.worktreeMeta[key] } } + for (const [childId, lineage] of Object.entries(this.state.worktreeLineageById)) { + if (childId.startsWith(prefix) || lineage.parentWorktreeId.startsWith(prefix)) { + delete this.state.worktreeLineageById[childId] + } + } this.scheduleSave() } @@ -1143,6 +1150,9 @@ export class Store { setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() const updated = { ...existing, ...meta } + if (!updated.instanceId) { + updated.instanceId = randomUUID() + } this.state.worktreeMeta[worktreeId] = updated this.scheduleSave() return updated @@ -1150,6 +1160,26 @@ export class Store { removeWorktreeMeta(worktreeId: string): void { delete this.state.worktreeMeta[worktreeId] + delete this.state.worktreeLineageById[worktreeId] + this.scheduleSave() + } + + getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { + return this.state.worktreeLineageById[worktreeId] + } + + getAllWorktreeLineage(): Record { + return this.state.worktreeLineageById + } + + setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { + this.state.worktreeLineageById[worktreeId] = lineage + this.scheduleSave() + return lineage + } + + removeWorktreeLineage(worktreeId: string): void { + delete this.state.worktreeLineageById[worktreeId] this.scheduleSave() } @@ -1695,6 +1725,7 @@ export class Store { function getDefaultWorktreeMeta(): WorktreeMeta { return { + instanceId: randomUUID(), displayName: '', comment: '', linkedIssue: null, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cfba17c75c0..843dd10bd9a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeMeta } from '../../shared/types' +import type { WorktreeLineage, WorktreeMeta } from '../../shared/types' import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree' import { createSetupRunnerScript, @@ -30,6 +30,7 @@ const { removeWorktreeMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, + getSshGitProviderMock, invalidateAuthorizedRootsCacheMock } = vi.hoisted(() => ({ MOCK_GIT_WORKTREES: [ @@ -45,6 +46,7 @@ const { removeWorktreeMock: vi.fn(), computeWorktreePathMock: vi.fn(), ensurePathWithinWorkspaceMock: vi.fn(), + getSshGitProviderMock: vi.fn(), invalidateAuthorizedRootsCacheMock: vi.fn() })) @@ -54,6 +56,10 @@ vi.mock('../git/worktree', () => ({ removeWorktree: removeWorktreeMock })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + vi.mock('../hooks', () => ({ createSetupRunnerScript: vi.fn(), getEffectiveHooks: vi.fn().mockReturnValue(null), @@ -97,6 +103,7 @@ afterEach(() => { vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES) vi.mocked(addWorktree).mockReset() vi.mocked(removeWorktree).mockReset() + getSshGitProviderMock.mockReset() vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() vi.mocked(runHook).mockReset() @@ -143,6 +150,24 @@ function createRuntime(): OrcaRuntimeService { return new OrcaRuntimeService(store) } +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + const store = { getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id), getRepos: () => [ @@ -1492,6 +1517,772 @@ describe('OrcaRuntimeService', () => { await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit') }) + it('resolves SSH worktrees when manually updating lineage', async () => { + const remoteRepo = { + id: 'remote-repo', + path: '/home/user/repo', + displayName: 'remote', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const childId = `${remoteRepo.id}::/home/user/repo-child` + const parentId = `${remoteRepo.id}::/home/user/repo-parent` + const metaById: Record = { + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }), + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) + } + const setWorktreeLineage = vi.fn((_worktreeId, lineage) => lineage) + const listSshWorktrees = vi.fn().mockResolvedValue([ + { + path: '/home/user/repo-child', + head: 'abc', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + }, + { + path: '/home/user/repo-parent', + head: 'def', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + } + ]) + getSshGitProviderMock.mockReturnValue({ listWorktrees: listSshWorktrees }) + const runtimeStore = { + ...store, + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getRepos: () => [remoteRepo], + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(listSshWorktrees).toHaveBeenCalledWith(remoteRepo.path) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual' + }) + ) + }) + + it('ignores stale instance-mismatched lineage when validating manual cycle repairs', async () => { + const parentPath = '/tmp/worktree-a' + const childPath = '/tmp/worktree-b' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'new-parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const setWorktreeLineage = vi.fn((_worktreeId, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: (worktreeId: string) => + worktreeId === parentId + ? { + worktreeId: parentId, + worktreeInstanceId: 'old-parent-instance', + parentWorktreeId: childId, + parentWorktreeInstanceId: 'child-instance', + origin: 'manual' as const, + capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, + createdAt: 1 + } + : undefined, + setWorktreeLineage + } + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: parentPath, + head: 'abc', + branch: 'feature/a', + isBare: false, + isMainWorktree: false + }, + { + path: childPath, + head: 'def', + branch: 'feature/b', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'new-parent-instance' + }) + ) + }) + + it('backfills instanceId during runtime selector resolution for upgraded metadata', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/worktree-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta(), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { + ...metaById[worktreeId], + ...meta, + instanceId: meta.instanceId ?? metaById[worktreeId]?.instanceId ?? 'backfilled-instance' + } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage: vi.fn((_worktreeId: string, lineage) => lineage) + } + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: parentPath, + head: 'abc', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + }, + { + path: childPath, + head: 'def', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(runtimeStore.setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'backfilled-instance' + }) + ) + }) + + it('rotates a missing parent instance during runtime selector scans before same-path reuse', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/worktree-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'old-parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'old-parent-instance', + origin: 'manual' as const, + capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, + createdAt: 1 + } + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId], + getAllWorktreeLineage: () => lineageById, + removeWorktreeLineage: vi.fn((worktreeId: string) => { + delete lineageById[worktreeId] + }), + setWorktreeLineage + } + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValue([ + { + path: childPath, + head: 'def', + branch: 'feature/child', + isBare: false, + isMainWorktree: false + }, + { + path: parentPath, + head: 'abc', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.showManagedWorktree(`id:${childId}`) + const rotatedParentInstance = metaById[parentId].instanceId + expect(rotatedParentInstance).toBeTruthy() + expect(rotatedParentInstance).not.toBe('old-parent-instance') + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { comment: 'rescanned' }) + expect(metaById[parentId].instanceId).toBe(rotatedParentInstance) + + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { comment: 'touch' }) + await runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: rotatedParentInstance + }) + ) + }) + + it('does not prune lineage when a runtime local worktree scan fails', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/worktree-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + const removeWorktreeLineage = vi.fn((worktreeId: string) => { + delete lineageById[worktreeId] + }) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: vi.fn((worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }), + getAllWorktreeLineage: () => lineageById, + removeWorktreeLineage + } + vi.mocked(listWorktrees).mockRejectedValueOnce(new Error('git unavailable')) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await expect(runtime.showManagedWorktree(`id:${childId}`)).rejects.toThrow('selector_not_found') + + expect(removeWorktreeLineage).not.toHaveBeenCalled() + expect(runtimeStore.setWorktreeMeta).not.toHaveBeenCalled() + expect(lineageById[childId]).toBeTruthy() + expect(metaById[parentId].instanceId).toBe('parent-instance') + }) + + it('does not prune lineage when an SSH runtime provider is unavailable', async () => { + const remoteRepo = { + id: 'remote-repo', + path: '/home/user/repo', + displayName: 'remote', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const parentId = `${remoteRepo.id}::/home/user/repo-parent` + const childId = `${remoteRepo.id}::/home/user/repo-child` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + } + const removeWorktreeLineage = vi.fn((worktreeId: string) => { + delete lineageById[worktreeId] + }) + const runtimeStore = { + ...store, + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getRepos: () => [remoteRepo], + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: vi.fn((worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }), + getAllWorktreeLineage: () => lineageById, + removeWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await expect(runtime.showManagedWorktree(`id:${childId}`)).rejects.toThrow('selector_not_found') + + expect(removeWorktreeLineage).not.toHaveBeenCalled() + expect(runtimeStore.setWorktreeMeta).not.toHaveBeenCalled() + expect(lineageById[childId]).toBeTruthy() + expect(metaById[parentId].instanceId).toBe('parent-instance') + }) + + it('keeps valid orchestration lineage when caller terminal context is stale', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/workspaces/worker-child' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + displayName: 'coordinator' + }) + } + const setWorktreeLineage = vi.fn((worktreeId: string, lineage) => { + metaById[worktreeId] = metaById[worktreeId] ?? makeWorktreeMeta() + return lineage + }) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta({ instanceId: 'child-instance' }) + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: parentPath, + head: 'abc', + branch: 'feature/coordinator', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'worker-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'worker-child', + lineage: { + callerTerminalHandle: 'term_stale', + orchestrationContext: { + parentWorktreeId: parentId, + orchestrationRunId: 'run-1', + taskId: 'task-1', + coordinatorHandle: 'term_coord' + } + } + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: parentId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + orchestrationRunId: 'run-1', + taskId: 'task-1', + coordinatorHandle: 'term_coord' + }) + expect(result.lineage).not.toHaveProperty('createdByTerminalHandle') + expect(result.warnings).toEqual([]) + expect(setWorktreeLineage).toHaveBeenCalledWith(childId, expect.any(Object)) + }) + + it('enriches caller-terminal lineage with active orchestration dispatch context', async () => { + const workerPath = '/tmp/worktree-worker' + const childPath = '/tmp/workspaces/worker-child' + const childId = `${TEST_REPO_ID}::${childPath}` + const workerId = `${TEST_REPO_ID}::${workerPath}` + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ + instanceId: 'parent-instance', + displayName: 'coordinator' + }), + [workerId]: makeWorktreeMeta({ + instanceId: 'worker-instance', + displayName: 'worker' + }) + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta() + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const workerHandle = runtime.preAllocateHandleForPty('pty-worker') + const coordinatorHandle = runtime.preAllocateHandleForPty('pty-coordinator') + runtime.setOrchestrationDb({ + getActiveDispatchForTerminal: vi.fn(() => ({ + task_id: 'task-1' + })), + getActiveCoordinatorRun: vi.fn(() => ({ + id: 'run-1', + coordinator_handle: coordinatorHandle + })) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + title: 'Worker', + activeLeafId: 'pane:1', + layout: null + }, + { + tabId: 'tab-coordinator', + worktreeId: TEST_WORKTREE_ID, + title: 'Coordinator', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-worker', + paneTitle: null + }, + { + tabId: 'tab-coordinator', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 2, + ptyId: 'pty-coordinator', + paneTitle: null + } + ] + }) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + ...MOCK_GIT_WORKTREES, + { + path: workerPath, + head: 'fed', + branch: 'feature/worker', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'worker-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'worker-child', + lineage: { callerTerminalHandle: workerHandle } + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: workerId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + orchestrationRunId: 'run-1', + taskId: 'task-1', + coordinatorHandle, + createdByTerminalHandle: workerHandle + }) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + worktreeInstanceId: expect.not.stringMatching(/^old-/), + parentWorktreeInstanceId: 'worker-instance' + }) + ) + }) + + it('infers orchestration lineage from task-id comments when dispatch is completed', async () => { + const workerPath = '/tmp/worktree-worker' + const childPath = '/tmp/workspaces/worker-child' + const childId = `${TEST_REPO_ID}::${childPath}` + const workerId = `${TEST_REPO_ID}::${workerPath}` + const metaById: Record = { + [workerId]: makeWorktreeMeta({ + instanceId: 'worker-instance', + displayName: 'worker' + }) + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta() + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const workerHandle = runtime.preAllocateHandleForPty('pty-worker') + runtime.setOrchestrationDb({ + getDispatchContext: vi.fn(() => ({ + task_id: 'task_abc123', + assignee_handle: workerHandle, + status: 'completed' + })) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + title: 'Worker', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-worker', + worktreeId: workerId, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-worker', + paneTitle: null + } + ] + }) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: workerPath, + head: 'fed', + branch: 'feature/worker', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'worker-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'worker-child', + comment: 'Created via orchestration task task_abc123' + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: workerId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + taskId: 'task_abc123' + }) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + parentWorktreeInstanceId: 'worker-instance' + }) + ) + }) + + it('infers orchestration lineage from task creator when no dispatch context exists', async () => { + const parentPath = '/tmp/worktree-parent' + const childPath = '/tmp/workspaces/parent-child' + const childId = `${TEST_REPO_ID}::${childPath}` + const parentId = `${TEST_REPO_ID}::${parentPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + displayName: 'parent' + }) + } + const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage) + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + const existing = metaById[worktreeId] ?? makeWorktreeMeta() + metaById[worktreeId] = { ...existing, ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const parentHandle = runtime.preAllocateHandleForPty('pty-parent') + runtime.setOrchestrationDb({ + getDispatchContext: vi.fn(() => undefined), + getTask: vi.fn(() => ({ + id: 'task_creator123', + created_by_terminal_handle: parentHandle + })) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-parent', + worktreeId: parentId, + title: 'Parent', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-parent', + worktreeId: parentId, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-parent', + paneTitle: null + } + ] + }) + computeWorktreePathMock.mockReturnValue(childPath) + ensurePathWithinWorkspaceMock.mockReturnValue(childPath) + vi.mocked(listWorktrees) + .mockResolvedValueOnce([ + { + path: parentPath, + head: 'fed', + branch: 'feature/parent', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: childPath, + head: 'def', + branch: 'parent-child', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'parent-child', + comment: 'Created via orchestration task task_creator123' + }) + + expect(result.lineage).toMatchObject({ + worktreeId: childId, + parentWorktreeId: parentId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + taskId: 'task_creator123' + }) + expect(setWorktreeLineage).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + parentWorktreeInstanceId: 'parent-instance' + }) + ) + }) + it('returns a setup launch payload for CLI-created worktrees when hooks are explicitly enabled', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2b053f310dc..617045f06d4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -16,9 +16,12 @@ import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' import type { CreateWorktreeResult, + GitWorktreeInfo, GlobalSettings, Repo, StatsSummary, + WorktreeLineage, + WorktreeLineageWarning, WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent, WorktreeStartupLaunch @@ -138,6 +141,7 @@ import { } from '../hooks' import { REPO_COLORS, getDefaultVoiceSettings } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' import { AgentDetector } from '../stats/agent-detector' @@ -214,6 +218,10 @@ type RuntimeStore = { getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] removeWorktreeMeta: Store['removeWorktreeMeta'] + getWorktreeLineage?: Store['getWorktreeLineage'] + getAllWorktreeLineage?: Store['getAllWorktreeLineage'] + setWorktreeLineage?: Store['setWorktreeLineage'] + removeWorktreeLineage?: Store['removeWorktreeLineage'] getGitHubCache: Store['getGitHubCache'] getWorkspaceSession?: Store['getWorkspaceSession'] getSettings(): { @@ -405,6 +413,7 @@ type MessageWaiter = { type ResolvedWorktree = { id: string + instanceId?: string repoId: string path: string branch: string @@ -420,6 +429,62 @@ type ResolvedWorktree = { comment: string } +type WorktreeLineageInput = { + parentWorktree?: string + noParent?: boolean + callerTerminalHandle?: string + comment?: string + orchestrationContext?: { + parentWorktreeId?: string + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string + } +} + +type WorktreeLineageResolution = + | { + kind: 'lineage' + parent: ResolvedWorktree + origin: WorktreeLineage['origin'] + capture: WorktreeLineage['capture'] + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string + createdByTerminalHandle?: string + } + | { + kind: 'none' + warnings: WorktreeLineageWarning[] + } + +type RuntimeWorktreeScanResult = + | { ok: true; worktrees: GitWorktreeInfo[] } + | { ok: false; worktrees: [] } + +type WorktreeLineageCandidate = { + source: 'terminal-context' | 'orchestration-context' + parent: ResolvedWorktree + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string +} + +function extractOrchestrationTaskId(text?: string): string | undefined { + return text?.match(/\btask_[A-Za-z0-9]+\b/)?.[0] +} + +class RuntimeLineageError extends Error { + code: string + data?: unknown + + constructor(code: string, message: string, data?: unknown) { + super(message) + this.code = code + this.data = data + } +} + type BrowserCommandTargetParams = { worktree?: string page?: string @@ -4132,6 +4197,7 @@ export class OrcaRuntimeService { activate?: boolean setupDecision?: 'run' | 'skip' | 'inherit' startup?: WorktreeStartupLaunch + lineage?: WorktreeLineageInput }): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -4141,6 +4207,9 @@ export class OrcaRuntimeService { if (isFolderRepo(repo)) { throw new Error('Folder mode does not support creating worktrees.') } + const lineageInput = + args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined + const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) const settings = this.store.getSettings() const requestedName = args.name const sanitizedName = sanitizeWorktreeName(args.name) @@ -4216,6 +4285,10 @@ export class OrcaRuntimeService { const worktreeId = `${repo.id}::${created.path}` const now = Date.now() const meta = this.store.setWorktreeMeta(worktreeId, { + // Why: worktree IDs are path-derived. If a path is deleted outside Orca + // and later recreated, creation must mint a fresh instance identity so + // stale lineage records tied to the old occupant fail validation. + instanceId: randomUUID(), lastActivityAt: now, // See createRemoteWorktree: createdAt grants the new worktree a grace // window in Recent sort so ambient PTY bumps in OTHER worktrees can't @@ -4230,6 +4303,44 @@ export class OrcaRuntimeService { ...(args.comment !== undefined ? { comment: args.comment } : {}) }) const worktree = mergeWorktree(repo.id, created, meta) + let lineage: WorktreeLineage | null = null + const lineageWarnings = lineageResolution.kind === 'none' ? [...lineageResolution.warnings] : [] + if (lineageResolution.kind === 'lineage') { + const childInstanceId = meta.instanceId + const parentInstanceId = lineageResolution.parent.instanceId + if (childInstanceId && parentInstanceId && this.store.setWorktreeLineage) { + lineage = this.store.setWorktreeLineage(worktreeId, { + worktreeId, + worktreeInstanceId: childInstanceId, + parentWorktreeId: lineageResolution.parent.id, + parentWorktreeInstanceId: parentInstanceId, + origin: lineageResolution.origin, + capture: lineageResolution.capture, + ...(lineageResolution.orchestrationRunId + ? { orchestrationRunId: lineageResolution.orchestrationRunId } + : {}), + ...(lineageResolution.taskId ? { taskId: lineageResolution.taskId } : {}), + ...(lineageResolution.coordinatorHandle + ? { coordinatorHandle: lineageResolution.coordinatorHandle } + : {}), + ...(lineageResolution.createdByTerminalHandle + ? { createdByTerminalHandle: lineageResolution.createdByTerminalHandle } + : {}), + createdAt: now + }) + } else { + lineageWarnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not record lineage because instance identity was unavailable.', + details: { + childHasInstanceId: Boolean(childInstanceId), + parentHasInstanceId: Boolean(parentInstanceId), + storeSupportsLineage: Boolean(this.store.setWorktreeLineage) + } + }) + } + } let setup: CreateWorktreeResult['setup'] let warning: string | undefined @@ -4356,6 +4467,7 @@ export class OrcaRuntimeService { } return { worktree, + ...(lineageInput ? { lineage, warnings: lineageWarnings } : {}), ...(setup ? { setup } : {}), ...(warning ? { warning } : {}) } @@ -4701,12 +4813,43 @@ export class OrcaRuntimeService { linkedIssue?: number | null comment?: string isPinned?: boolean + lineage?: { + parentWorktree?: string + noParent?: boolean + } } ) { if (!this.store) { throw new Error('runtime_unavailable') } const worktree = await this.resolveWorktreeSelector(worktreeSelector) + if (updates.lineage?.noParent === true) { + this.store.removeWorktreeLineage?.(worktree.id) + } else if (updates.lineage?.parentWorktree) { + const parent = await this.resolveWorktreeSelector(updates.lineage.parentWorktree) + this.validateLineageParent(worktree, parent) + if (!worktree.instanceId || !parent.instanceId) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_MISSING', + 'Workspace instance identity was unavailable.' + ) + } + if (!this.store.setWorktreeLineage) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_MISSING', + 'Workspace lineage storage was unavailable.' + ) + } + this.store.setWorktreeLineage(worktree.id, { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: Date.now() + }) + } const meta = this.store.setWorktreeMeta(worktree.id, { ...(updates.displayName !== undefined ? { displayName: updates.displayName } : {}), ...(updates.linkedIssue !== undefined ? { linkedIssue: updates.linkedIssue } : {}), @@ -5421,6 +5564,279 @@ export class OrcaRuntimeService { throw new Error('selector_not_found') } + private validateLineageParent(child: ResolvedWorktree, parent: ResolvedWorktree): void { + const childWorktreeId = child.id + const parentWorktreeId = parent.id + if (childWorktreeId === parentWorktreeId) { + throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A workspace cannot parent itself.') + } + const instanceByWorktreeId = new Map( + this.resolvedWorktreeCache?.worktrees.map((worktree) => [ + worktree.id, + worktree.instanceId + ]) ?? [ + [child.id, child.instanceId], + [parent.id, parent.instanceId] + ] + ) + let cursor: string | undefined = parentWorktreeId + const visited = new Set([childWorktreeId]) + while (cursor) { + if (visited.has(cursor)) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CYCLE', + 'Parent workspace would create a lineage cycle.' + ) + } + visited.add(cursor) + const lineage = this.store?.getWorktreeLineage?.(cursor) + if (!lineage) { + break + } + const cursorInstanceId = instanceByWorktreeId.get(cursor) + const parentInstanceId = instanceByWorktreeId.get(lineage.parentWorktreeId) + if ( + cursorInstanceId !== lineage.worktreeInstanceId || + parentInstanceId !== lineage.parentWorktreeInstanceId + ) { + break + } + cursor = lineage.parentWorktreeId + } + } + + private async resolveLineageForWorktreeCreate( + input?: WorktreeLineageInput + ): Promise { + if (!input) { + return { kind: 'none', warnings: [] } + } + + if (input.noParent === true && input.parentWorktree) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_CONFLICT', + 'Choose either --parent-worktree or --no-parent, not both.' + ) + } + + if (input.noParent === true) { + return { kind: 'none', warnings: [] } + } + + if (input.parentWorktree) { + try { + const parent = await this.resolveWorktreeSelector(input.parentWorktree) + return { + kind: 'lineage', + parent, + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' } + } + } catch { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_NOT_FOUND', + 'Parent workspace was not found.', + { + nextSteps: [ + 'Run `orca worktree list` and pass a valid --parent-worktree selector.', + 'Retry with --no-parent to create without lineage.' + ] + } + ) + } + } + + const warnings: WorktreeLineageWarning[] = [] + const candidates: WorktreeLineageCandidate[] = [] + let terminalContextResolved = false + + if (input.orchestrationContext?.parentWorktreeId) { + try { + candidates.push({ + source: 'orchestration-context', + parent: await this.resolveWorktreeSelector( + `id:${input.orchestrationContext.parentWorktreeId}` + ) + }) + } catch { + // Keep creation recoverable; the warning below covers missing inferred context. + } + } + + const commentTaskId = extractOrchestrationTaskId(input.comment) + if (commentTaskId) { + const candidate = await this.resolveLineageCandidateForTaskId(commentTaskId) + if (candidate) { + candidates.push(candidate) + } + } + + if (input.callerTerminalHandle) { + try { + const terminal = await this.showTerminal(input.callerTerminalHandle) + const terminalParent = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`) + const activeDispatch = this._orchestrationDb?.getActiveDispatchForTerminal( + input.callerTerminalHandle + ) + const activeRun = this._orchestrationDb?.getActiveCoordinatorRun() + if (activeDispatch) { + candidates.push({ + source: 'orchestration-context', + parent: terminalParent, + taskId: activeDispatch.task_id, + ...(activeRun + ? { + orchestrationRunId: activeRun.id, + coordinatorHandle: activeRun.coordinator_handle + } + : {}) + }) + } else { + candidates.push({ + source: 'terminal-context', + parent: terminalParent + }) + } + terminalContextResolved = true + } catch { + // Why: terminal handles can go stale during reloads or SSH reconnects. + // A valid orchestration parent is still authoritative, so keep resolving + // other inferred candidates instead of dropping lineage completely. + warnings.push({ + code: 'LINEAGE_PARENT_CONTEXT_MISSING', + message: + 'Worktree created, but Orca could not validate the caller terminal as a parent workspace.', + details: { callerTerminalHandle: input.callerTerminalHandle } + }) + } + } + + if (candidates.length === 0) { + return { kind: 'none', warnings } + } + + const [first] = candidates + const conflict = candidates.find((candidate) => candidate.parent.id !== first.parent.id) + if (conflict) { + return { + kind: 'none', + warnings: [ + { + code: 'LINEAGE_PARENT_CONTEXT_CONFLICT', + message: 'Worktree created, but Orca could not prove which parent workspace caused it.', + details: { + terminalParentWorktreeId: candidates.find((c) => c.source === 'terminal-context') + ?.parent.id, + orchestrationParentWorktreeId: candidates.find( + (c) => c.source === 'orchestration-context' + )?.parent.id + } + } + ] + } + } + + const preferred = + candidates.find((candidate) => candidate.source === 'orchestration-context') ?? first + return { + kind: 'lineage', + parent: preferred.parent, + origin: preferred.source === 'orchestration-context' ? 'orchestration' : 'cli', + capture: { source: preferred.source, confidence: 'inferred' }, + ...((preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId) + ? { + orchestrationRunId: + preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId + } + : {}), + ...((preferred.taskId ?? input.orchestrationContext?.taskId) + ? { taskId: preferred.taskId ?? input.orchestrationContext?.taskId } + : {}), + ...((preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle) + ? { + coordinatorHandle: + preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle + } + : {}), + ...(terminalContextResolved && input.callerTerminalHandle + ? { createdByTerminalHandle: input.callerTerminalHandle } + : {}) + } + } + + private async resolveLineageCandidateForTaskId( + taskId: string + ): Promise { + const db = this.getOrchestrationDbIfAvailable() + const dispatch = db?.getDispatchContext(taskId) + // Why: agent-created task records may never be dispatched, but the + // creating terminal still identifies the parent workspace for descendants. + const parentHandle = + dispatch?.assignee_handle ?? db?.getTask(taskId)?.created_by_terminal_handle + if (!parentHandle) { + return null + } + try { + const terminal = await this.showTerminal(parentHandle) + return { + source: 'orchestration-context', + parent: await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`), + taskId + } + } catch { + return null + } + } + + private getOrchestrationDbIfAvailable(): OrchestrationDb | null { + try { + return this._orchestrationDb ?? this.getOrchestrationDb() + } catch { + return this._orchestrationDb + } + } + + async hydrateInferredWorktreeLineage(): Promise { + const store = this.store + if ( + !store || + typeof store.getWorktreeLineage !== 'function' || + typeof store.setWorktreeLineage !== 'function' + ) { + return + } + + const worktrees = await this.listResolvedWorktrees() + for (const worktree of worktrees) { + if (store.getWorktreeLineage(worktree.id) || !worktree.instanceId) { + continue + } + const taskId = extractOrchestrationTaskId(worktree.comment) + if (!taskId) { + continue + } + const candidate = await this.resolveLineageCandidateForTaskId(taskId) + if (!candidate?.parent.instanceId || candidate.parent.id === worktree.id) { + continue + } + try { + this.validateLineageParent(worktree, candidate.parent) + } catch { + continue + } + store.setWorktreeLineage(worktree.id, { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId, + parentWorktreeId: candidate.parent.id, + parentWorktreeInstanceId: candidate.parent.instanceId, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'inferred' }, + taskId, + createdAt: Date.now() + }) + } + } + private async resolveNotesScope(worktreeSelector: string): Promise<{ projectId: string worktreeId: string @@ -5510,25 +5926,37 @@ export class OrcaRuntimeService { return [] } const now = Date.now() - const metaById = this.store.getAllWorktreeMeta() const perRepoWorktrees = await Promise.all( this.store.getRepos().map(async (repo) => { // Why: mobile startup RPCs share this path. A slow repo scan should // degrade one repo's metadata, not block all terminal/session loading. - const gitWorktrees = await withTimeout( - listRepoWorktrees(repo), + const scan = await withTimeout( + this.listRepoWorktreesForResolution(repo), RESOLVED_WORKTREE_REPO_TIMEOUT_MS, - [] + { ok: false, worktrees: [] } ) + const gitWorktrees = scan.worktrees + if (scan.ok) { + this.pruneLineageForMissingRepoWorktrees(repo, gitWorktrees) + } + const metaById = this.store?.getAllWorktreeMeta() ?? {} return gitWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` - const merged = mergeWorktree(repo.id, gitWorktree, metaById[worktreeId], repo.displayName) + // Why: lineage validation needs a durable instance ID even when the + // runtime sees a workspace before the renderer's discovery-stamp path. + const existingMeta = metaById[worktreeId] + const meta = + existingMeta && existingMeta.instanceId + ? existingMeta + : this.store?.setWorktreeMeta(worktreeId, {}) + const merged = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) return { id: merged.id, + ...(merged.instanceId !== undefined ? { instanceId: merged.instanceId } : {}), repoId: repo.id, path: merged.path, branch: merged.branch, - linkedIssue: metaById[worktreeId]?.linkedIssue ?? null, + linkedIssue: meta?.linkedIssue ?? null, git: { path: gitWorktree.path, head: gitWorktree.head, @@ -5553,6 +5981,50 @@ export class OrcaRuntimeService { return worktrees } + private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void { + const store = this.store + if ( + !store || + typeof store.getAllWorktreeLineage !== 'function' || + typeof store.removeWorktreeLineage !== 'function' + ) { + return + } + const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) + const repoPrefix = `${repo.id}::` + for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { + if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { + // Why: runtime selector scans can be the only scan before a path is + // reused. Once a successful scan proves the child is gone, stale + // lineage must not survive into the replacement checkout. + store.removeWorktreeLineage(childId) + } + if ( + lineage.parentWorktreeId.startsWith(repoPrefix) && + !liveIds.has(lineage.parentWorktreeId) + ) { + const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) + if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { + // Why: preserving child lineage powers the repair UI, but a missing + // parent path only needs one fresh identity to keep same-path + // replacement checkouts from validating old lineage. + store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) + } + } + } + } + + private async listRepoWorktreesForResolution(repo: Repo): Promise { + if (!repo.connectionId) { + return { ok: true, worktrees: await listRepoWorktrees(repo) } + } + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + return { ok: false, worktrees: [] } + } + return { ok: true, worktrees: await provider.listWorktrees(repo.path) } + } + private async getResolvedWorktreeMap(): Promise> { return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree])) } diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 9e9d5d941b3..2f6b7f089a0 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -132,6 +132,17 @@ describe('OrchestrationDb', () => { expect(task.deps).toBe('[]') }) + it('persists the creating terminal handle for task-created worktrees', () => { + const d = createDb() + const task = d.createTask({ + spec: 'spawn related workspace', + createdByTerminalHandle: 'term_creator' + }) + + expect(task.created_by_terminal_handle).toBe('term_creator') + expect(d.getTask(task.id)?.created_by_terminal_handle).toBe('term_creator') + }) + it('creates a task with deps as pending', () => { const d = createDb() const parent = d.createTask({ spec: 'parent' }) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index e8daa9f1708..8bfe15991cb 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -36,10 +36,10 @@ function generateId(prefix: string): string { // Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at` // column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so // push-on-idle can distinguish queued-but-undelivered from user-acknowledged -// messages without touching the `read` bit (check-wait PR). Bumping together -// since both PRs ship in the same release — existing on-disk DBs must clear -// both gaps in one atomic migration. -const SCHEMA_VERSION = 3 +// messages without touching the `read` bit (check-wait PR). v3 → v4 records +// the terminal that created a task so task-record worktree creation can infer +// the parent workspace even when no dispatch context exists. +const SCHEMA_VERSION = 4 export class OrchestrationDb { private db: Database.Database @@ -83,6 +83,7 @@ export class OrchestrationDb { CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, parent_id TEXT, + created_by_terminal_handle TEXT, spec TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ( @@ -227,6 +228,11 @@ export class OrchestrationDb { this.db.exec(`ALTER TABLE messages ADD COLUMN delivered_at TEXT`) } } + if (current < 4) { + if (!this.hasColumn('tasks', 'created_by_terminal_handle')) { + this.db.exec(`ALTER TABLE tasks ADD COLUMN created_by_terminal_handle TEXT`) + } + } this.db.pragma(`user_version = ${SCHEMA_VERSION}`) this.db.exec('COMMIT') @@ -367,14 +373,28 @@ export class OrchestrationDb { // ── Tasks ── - createTask(task: { spec: string; deps?: string[]; parentId?: string }): TaskRow { + createTask(task: { + spec: string + deps?: string[] + parentId?: string + createdByTerminalHandle?: string + }): TaskRow { const id = generateId('task') const depsJson = JSON.stringify(task.deps ?? []) const hasDeps = (task.deps ?? []).length > 0 const status: TaskStatus = hasDeps ? 'pending' : 'ready' this.db - .prepare('INSERT INTO tasks (id, parent_id, spec, status, deps) VALUES (?, ?, ?, ?, ?)') - .run(id, task.parentId ?? null, task.spec, status, depsJson) + .prepare( + 'INSERT INTO tasks (id, parent_id, created_by_terminal_handle, spec, status, deps) VALUES (?, ?, ?, ?, ?, ?)' + ) + .run( + id, + task.parentId ?? null, + task.createdByTerminalHandle ?? null, + task.spec, + status, + depsJson + ) return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow } diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index 03a31391cc9..f966368b937 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -37,6 +37,7 @@ export type MessageRow = { export type TaskRow = { id: string parent_id: string | null + created_by_terminal_handle: string | null spec: string status: TaskStatus deps: string diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index efb2044118d..a5c05387857 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -62,6 +62,20 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow ) { return errorResponse(id, meta, (error as { code: string }).code, message) } + if ( + error instanceof Error && + 'code' in error && + typeof (error as { code: unknown }).code === 'string' && + (error as { code: string }).code.startsWith('LINEAGE_') + ) { + return errorResponse( + id, + meta, + (error as { code: string }).code, + message, + (error as { data?: unknown }).data + ) + } if (RUNTIME_PASSTHROUGH_CODES.has(message)) { return errorResponse(id, meta, message, message) } diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 3ae1d213586..edbcd2dcd81 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -441,6 +441,16 @@ describe('orchestration RPC methods', () => { expect(result.task.status).toBe('pending') }) + it('records the caller terminal handle when creating a task', async () => { + setup() + const result = (await call('orchestration.taskCreate', { + spec: 'spawn related workspace', + callerTerminalHandle: 'term_creator' + })) as { task: { id: string } } + + expect(db.getTask(result.task.id)?.created_by_terminal_handle).toBe('term_creator') + }) + it('rejects invalid deps JSON', async () => { setup() await expect( diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index e1a2d68b943..02299143751 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -81,7 +81,8 @@ const InboxParams = z.object({ const TaskCreateParams = z.object({ spec: requiredString('Missing --spec'), deps: OptionalString, - parent: OptionalString + parent: OptionalString, + callerTerminalHandle: OptionalString }) const TaskListParams = z.object({ @@ -322,7 +323,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const task = db.createTask({ spec: params.spec, deps, - parentId: params.parent + parentId: params.parent, + createdByTerminalHandle: params.callerTerminalHandle }) return { task } } diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts new file mode 100644 index 00000000000..6f3e60d1331 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' +import { WORKTREE_METHODS } from './worktree' +import type { RpcContext } from '../core' +import { OrcaRuntimeService } from '../../orca-runtime' + +describe('worktree RPC methods', () => { + function findMethod(name: string) { + const method = WORKTREE_METHODS.find((m) => m.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + return method + } + + async function call(name: string, params: Record, runtime: OrcaRuntimeService) { + const method = findMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, { runtime } as RpcContext) + } + + it('rejects worktree.create when both parent and no-parent are supplied', async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'createManagedWorktree') + + await expect( + call( + 'worktree.create', + { + repo: 'id:repo-1', + name: 'child', + parentWorktree: 'id:parent', + noParent: true + }, + runtime + ) + ).rejects.toThrow('Choose either --parent-worktree or --no-parent, not both.') + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + }) + + it('rejects worktree.set when both parent and no-parent are supplied', async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'updateManagedWorktreeMeta') + + await expect( + call( + 'worktree.set', + { + worktree: 'id:child', + parentWorktree: 'id:parent', + noParent: true + }, + runtime + ) + ).rejects.toThrow('Choose either --parent-worktree or --no-parent, not both.') + expect(runtime.updateManagedWorktreeMeta).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index e3358fa731b..a641c34d86c 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -23,34 +23,63 @@ const WorktreeSelector = z.object({ .pipe(z.string().min(1, 'Missing worktree selector')) }) -const WorktreeCreate = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - name: OptionalString, - baseBranch: OptionalString, - linkedIssue: TriStateLinkedIssue, - comment: OptionalString, - runHooks: OptionalBoolean, - activate: OptionalBoolean, - setupDecision: z - .unknown() - .transform((v) => - typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined - ) - .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) - .optional(), - // Why: mobile clients pass a startup command (e.g. 'claude') so the first - // terminal pane launches the selected agent instead of an idle shell. - startupCommand: OptionalString -}) +const WorktreeCreate = z + .object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + name: OptionalString, + baseBranch: OptionalString, + linkedIssue: TriStateLinkedIssue, + comment: OptionalString, + runHooks: OptionalBoolean, + activate: OptionalBoolean, + parentWorktree: OptionalString, + noParent: OptionalBoolean, + callerTerminalHandle: OptionalString, + orchestrationContext: z + .object({ + parentWorktreeId: OptionalString, + orchestrationRunId: OptionalString, + taskId: OptionalString, + coordinatorHandle: OptionalString + }) + .optional(), + setupDecision: z + .unknown() + .transform((v) => + typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined + ) + .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) + .optional(), + // Why: mobile clients pass a startup command (e.g. 'claude') so the first + // terminal pane launches the selected agent instead of an idle shell. + startupCommand: OptionalString + }) + .superRefine((params, ctx) => { + if (params.parentWorktree && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either --parent-worktree or --no-parent, not both.' + }) + } + }) const WorktreeSet = WorktreeSelector.extend({ displayName: OptionalString, linkedIssue: TriStateLinkedIssue, comment: OptionalString, - isPinned: OptionalBoolean + isPinned: OptionalBoolean, + parentWorktree: OptionalString, + noParent: OptionalBoolean +}).superRefine((params, ctx) => { + if (params.parentWorktree && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either --parent-worktree or --no-parent, not both.' + }) + } }) const WorktreeRemove = WorktreeSelector.extend({ @@ -99,7 +128,13 @@ export const WORKTREE_METHODS: RpcMethod[] = [ runHooks: params.runHooks === true, activate: params.activate === true, setupDecision: params.setupDecision, - startup: params.startupCommand ? { command: params.startupCommand } : undefined + startup: params.startupCommand ? { command: params.startupCommand } : undefined, + lineage: { + parentWorktree: params.parentWorktree, + noParent: params.noParent === true, + callerTerminalHandle: params.callerTerminalHandle, + orchestrationContext: params.orchestrationContext + } }) }), defineMethod({ @@ -110,7 +145,14 @@ export const WORKTREE_METHODS: RpcMethod[] = [ displayName: params.displayName, linkedIssue: params.linkedIssue, comment: params.comment, - isPinned: params.isPinned + isPinned: params.isPinned, + lineage: + params.parentWorktree || params.noParent === true + ? { + parentWorktree: params.parentWorktree, + noParent: params.noParent === true + } + : undefined }) }) }), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c988c5561af..6a7f97484ee 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -79,6 +79,7 @@ import type { UpdateStatus, Worktree, WorktreeBaseStatusEvent, + WorktreeLineage, WorktreeMeta, WorktreeRemoteBranchConflictEvent, WorktreeSetupLaunch, @@ -519,6 +520,12 @@ export type PreloadApi = { }) => Promise<{ baseBranch: string } | { error: string }> remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise + listLineage: () => Promise> + updateLineage: (args: { + worktreeId: string + parentWorktreeId?: string + noParent?: boolean + }) => Promise persistSortOrder: (args: { orderedIds: string[] }) => Promise onChanged: (callback: (data: { repoId: string }) => void) => () => void onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 88bdf910a7c..5d26e777f4e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -460,6 +460,15 @@ const api = { updates: Record }): Promise => ipcRenderer.invoke('worktrees:updateMeta', args), + listLineage: (): Promise> => + ipcRenderer.invoke('worktrees:listLineage'), + + updateLineage: (args: { + worktreeId: string + parentWorktreeId?: string + noParent?: boolean + }): Promise => ipcRenderer.invoke('worktrees:updateLineage', args), + persistSortOrder: (args: { orderedIds: string[] }): Promise => ipcRenderer.invoke('worktrees:persistSortOrder', args), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 68a809ddd05..daf6cea4c0d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -184,6 +184,7 @@ function App(): React.JSX.Element { toggleSidebar: s.toggleSidebar, fetchRepos: s.fetchRepos, fetchAllWorktrees: s.fetchAllWorktrees, + fetchWorktreeLineage: s.fetchWorktreeLineage, fetchSettings: s.fetchSettings, initGitHubCache: s.initGitHubCache, refreshAllGitHub: s.refreshAllGitHub, @@ -284,6 +285,7 @@ function App(): React.JSX.Element { const sidebarWidth = useAppStore((s) => s.sidebarWidth) const sidebarOpen = useAppStore((s) => s.sidebarOpen) const groupBy = useAppStore((s) => s.groupBy) + const showWorkspaceLineage = useAppStore((s) => s.showWorkspaceLineage) const sortBy = useAppStore((s) => s.sortBy) const showActiveOnly = useAppStore((s) => s.showActiveOnly) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) @@ -368,6 +370,7 @@ function App(): React.JSX.Element { try { await actions.fetchRepos() await actions.fetchAllWorktrees() + await actions.fetchWorktreeLineage() const persistedUI = await window.api.ui.get() uiHydrated = hydratePersistedUIAfterStartupRead({ persistedUI, @@ -722,6 +725,7 @@ function App(): React.JSX.Element { sidebarWidth, rightSidebarWidth, groupBy, + showWorkspaceLineage, sortBy, showActiveOnly, hideDefaultBranchWorkspace, @@ -741,6 +745,7 @@ function App(): React.JSX.Element { sidebarWidth, rightSidebarWidth, groupBy, + showWorkspaceLineage, sortBy, showActiveOnly, hideDefaultBranchWorkspace, diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index e84bde04b85..83ea2a2261b 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -62,6 +62,8 @@ const SidebarHeader = React.memo(function SidebarHeader() { const setSortBy = useAppStore((s) => s.setSortBy) const groupBy = useAppStore((s) => s.groupBy) const setGroupBy = useAppStore((s) => s.setGroupBy) + const showWorkspaceLineage = useAppStore((s) => s.showWorkspaceLineage) + const setShowWorkspaceLineage = useAppStore((s) => s.setShowWorkspaceLineage) return (
@@ -114,6 +116,15 @@ const SidebarHeader = React.memo(function SidebarHeader() {
+ + setShowWorkspaceLineage(Boolean(checked))} + onSelect={(e) => e.preventDefault()} + > + Nest child workspaces + + Sort by , worktreeId: string) => boolean onContextMenuSelect?: (event: React.MouseEvent) => readonly Worktree[] } @@ -69,7 +72,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ selectedWorktrees, onSelectionGesture, onContextMenuSelect, - hideRepoBadge + hideRepoBadge, + parentLabel, + lineageState }: WorktreeCardProps) { const openModal = useAppStore((s) => s.openModal) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) @@ -646,6 +651,23 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} + + {parentLabel && ( + + + + {lineageState === 'missing' ? 'Missing parent' : `from ${parentLabel}`} + + + )} {/* Meta section: Issue / hosted review / Comment diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 59ac54b5245..e3830d57824 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -18,15 +18,19 @@ import { Pencil, Pin, PinOff, - Trash2 + Trash2, + Unlink, + Workflow } from 'lucide-react' import { useAppStore } from '@/store' -import { useRepoById, useRepoMap } from '@/store/selectors' +import { useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors' import { cn } from '@/lib/utils' import type { Worktree } from '../../../../shared/types' import { isFolderRepo } from '../../../../shared/repo-kind' import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow' import { runSleepWorktrees } from './sleep-worktree-flow' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { getLineageRenderInfo } from './worktree-list-groups' type Props = { worktree: Worktree @@ -56,6 +60,10 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const isDeleting = deleteState?.isDeleting ?? false const isFolder = repo ? isFolderRepo(repo) : false const repoMap = useRepoMap() + const worktreeMap = useWorktreeMap() + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) + const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) @@ -92,6 +100,17 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ isMultiContext && batchDeleteWorktrees.length > 0 ? `Delete ${batchDeleteWorktrees.length} Workspace${batchDeleteWorktrees.length === 1 ? '' : 's'}` : 'Delete Selected' + const lineage = worktreeLineageById[worktree.id] + // Why: path-derived worktree IDs can be reused. The menu must honor the same + // instance check as grouped rows before offering navigation to a parent. + const lineageInfo = useMemo( + () => getLineageRenderInfo(worktree, worktreeLineageById, worktreeMap), + [worktree, worktreeLineageById, worktreeMap] + ) + const validParentWorktreeId = lineageInfo.state === 'valid' ? lineageInfo.parent.id : null + const hasAnyContextLineage = activeContextWorktrees.some((item) => worktreeLineageById[item.id]) + const canGroupUnderActive = + activeWorktreeId != null && activeContextWorktrees.every((item) => item.id !== activeWorktreeId) useEffect(() => { const closeMenu = (): void => setMenuOpen(false) @@ -206,6 +225,29 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree.repoId ]) + const handleOpenParent = useCallback(() => { + if (validParentWorktreeId) { + activateAndRevealWorktree(validParentWorktreeId) + } + }, [validParentWorktreeId]) + + const handleGroupUnderActive = useCallback(() => { + if (!activeWorktreeId) { + return + } + void Promise.all( + activeContextWorktrees.map((item) => + updateWorktreeLineage(item.id, { parentWorktreeId: activeWorktreeId }) + ) + ) + }, [activeContextWorktrees, activeWorktreeId, updateWorktreeLineage]) + + const handleRemoveParentLink = useCallback(() => { + void Promise.all( + activeContextWorktrees.map((item) => updateWorktreeLineage(item.id, { noParent: true })) + ) + }, [activeContextWorktrees, updateWorktreeLineage]) + return ( <>
+ + + Open Parent Workspace + + + + Group under Active Workspace + + {lineage && ( + + + Remove Parent Link + + )} + + + )} + {isMultiContext && ( + <> + + + Group under Active Workspace + + {hasAnyContextLineage && ( + + + Remove Parent Links + + )} + )} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index a58b538d123..a8a7e2c4bd9 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import { ChevronDown, CircleX, Plus } from 'lucide-react' +import { ChevronDown, ChevronRight, CircleX, Plus } from 'lucide-react' import { useAppStore } from '@/store' import { getAllWorktreesFromState, @@ -13,7 +13,7 @@ import WorktreeCard from './WorktreeCard' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import type { Worktree, Repo } from '../../../../shared/types' +import type { Worktree, Repo, WorktreeLineage } from '../../../../shared/types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { buildWorktreeComparator } from './smart-sort' import { @@ -29,7 +29,8 @@ import { ALL_GROUP_KEY, PINNED_GROUP_KEY, buildRows, - getGroupKeyForWorktree + getGroupKeyForWorktree, + getLineageGroupKey } from './worktree-list-groups' import { computeClearFilterActions, @@ -76,10 +77,91 @@ function getWorktreeOptionId(worktreeId: string): string { return `worktree-list-option-${encodeURIComponent(worktreeId)}` } +function LineageGuides({ row }: { row: Extract }) { + if (row.depth <= 0) { + return null + } + + const step = 18 + const lineX = 10 + const currentX = (row.depth - 1) * step + lineX + + return ( +
+ {row.lineageTrail + .slice(0, -1) + .map((continues, index) => + continues ? ( + + ) : null + )} + + +
+ ) +} + +function LineageDisclosure({ + row, + onToggle +}: { + row: Extract + onToggle: (event: React.MouseEvent) => void +}) { + if (!row.lineageGroupKey || row.lineageChildCount <= 0) { + return null + } + + const label = `${row.lineageCollapsed ? 'Show' : 'Hide'} ${row.lineageChildCount} child ${ + row.lineageChildCount === 1 ? 'workspace' : 'workspaces' + }` + + return ( + + + + + + {row.lineageCollapsed ? 'Show child workspaces' : 'Hide child workspaces'} + + + ) +} + type VirtualizedWorktreeViewportProps = { rows: Row[] activeWorktreeId: string | null groupBy: 'none' | 'repo' | 'pr-status' + showWorkspaceLineage: boolean toggleGroup: (key: string) => void collapsedGroups: Set handleCreateForRepo: (repoId: string) => void @@ -95,6 +177,8 @@ type VirtualizedWorktreeViewportProps = { worktree: Worktree ) => readonly Worktree[] repoMap: Map + worktreeMap: Map + worktreeLineageById: Record repoOrder: Map // The full canonical state.repos id ordering — the drag controller commits // permutations of this list, even when some repos aren't currently visible @@ -116,6 +200,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp rows, activeWorktreeId, groupBy, + showWorkspaceLineage, toggleGroup, collapsedGroups, handleCreateForRepo, @@ -128,6 +213,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onSelectionGesture, onContextMenuSelect, repoMap, + worktreeMap, + worktreeLineageById, repoOrder, allRepoIds, reorderRepos, @@ -222,6 +309,29 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp { const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktreeId) + if (targetWorktree && showWorkspaceLineage && !targetWorktree.isPinned) { + const seen = new Set() + let current: Worktree | undefined = targetWorktree + while (current && !seen.has(current.id)) { + seen.add(current.id) + const lineage = worktreeLineageById[current.id] + const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined + if ( + !lineage || + !parent || + current.instanceId !== lineage.worktreeInstanceId || + parent.instanceId !== lineage.parentWorktreeInstanceId + ) { + break + } + const lineageGroupKey = getLineageGroupKey(parent.id) + if (collapsedGroups.has(lineageGroupKey)) { + toggleGroup(lineageGroupKey) + } + current = parent + } + } + if (targetWorktree?.isPinned) { // Why: pinned worktrees live in the dedicated "Pinned" section regardless // of their PR-status / repo group. Only uncollapse the Pinned header @@ -267,6 +377,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktrees, repoMap, prCache, + worktreeLineageById, + worktreeMap, + showWorkspaceLineage, rows, virtualizer, clearPendingRevealWorktreeId, @@ -300,7 +413,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp repoMap, prCache, new Set(), - repoOrder + repoOrder, + worktreeLineageById, + worktreeMap, + showWorkspaceLineage ).filter((r): r is Extract => r.type === 'item') if (worktreeRows.length === 0) { return @@ -333,7 +449,19 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) } }, - [rows, activeWorktreeId, virtualizer, groupBy, worktrees, repoMap, prCache, repoOrder] + [ + rows, + activeWorktreeId, + virtualizer, + groupBy, + worktrees, + repoMap, + prCache, + repoOrder, + worktreeLineageById, + worktreeMap, + showWorkspaceLineage + ] ) useEffect(() => { @@ -493,7 +621,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp />
- {row.repo ? ( + {row.repo && groupBy === 'repo' ? (