Add workspace lineage grouping

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-15 01:13:54 -07:00
co-authored by Orca
parent c66e9801fe
commit 7948ef2ce8
40 changed files with 2800 additions and 107 deletions
+1
View File
@@ -66,6 +66,7 @@ tmp/
design-docs/
.context/
.atl/
/agent-hooks/
# Local-only design/planning docs (not checked in)
docs/*.md
+73
View File
@@ -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)
<!-- Issues that were fixed in the design doc. Reviewers should not re-report these. -->
<!-- Format: [iteration] | [severity] | [category] | [issue summary] | [how addressed] -->
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)
<!-- Issues reviewed but deemed acceptable for this context. Do not re-report. -->
<!-- Format: [iteration] | [severity] | [category] | [reason skipped] | [issue summary] -->
<!-- NOTE: Only P2/P3 issues may be skipped. P0/P1 must always be addressed. -->
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)
<!-- Findings that were challenged and determined to be false positives or noise. -->
<!-- Reviewers in future iterations must NOT resurface these. -->
<!-- Format: [iteration] | [original severity] | [finding summary] | [reason invalidated] -->
[Initially empty - populated after each validation phase]
## Findings History
<!-- Running log of findings across iterations for convergence tracking -->
### 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
+12
View File
@@ -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
}
+7 -1
View File
@@ -207,12 +207,18 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
},
'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}]`)
+59 -14
View File
@@ -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<string, CommandHandler> = {
'worktree ps': async ({ flags, client, json }) => {
const result = await client.call<RuntimeWorktreePsResult>('worktree.ps', {
@@ -50,20 +75,38 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
})
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<RuntimeWorktreeCreateResult>('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<string, CommandHandler> = {
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)
},
+2 -2
View File
@@ -140,10 +140,10 @@ Common Commands:
orca open [--json]
orca status [--json]
orca worktree list [--repo <selector>] [--limit <n>] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--activate] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree current [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--parent-worktree <selector>|--no-parent] [--json]
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
orca worktree ps [--limit <n>] [--json]
orca terminal list [--worktree <selector>] [--limit <n>] [--json]
+40 -5
View File
@@ -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'
+13 -3
View File
@@ -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 <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--activate] [--json]',
'orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--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 <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'display-name', 'issue', 'comment']
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--parent-worktree <selector>|--no-parent] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'worktree',
'display-name',
'issue',
'comment',
'parent-worktree',
'no-parent'
]
},
{
path: ['worktree', 'rm'],
+1
View File
@@ -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,
+9
View File
@@ -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<WorktreeMeta> = {
// 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<WorktreeMeta> = {
// 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.
+143 -1
View File
@@ -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<typeof vi.fn>
@@ -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
+66
View File
@@ -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
+31
View File
@@ -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>): 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<string, WorktreeLineage> {
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,
+792 -1
View File
@@ -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> = {}): 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<string, WorktreeMeta> = {
[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<WorktreeMeta>) => {
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<string, WorktreeMeta> = {
[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<WorktreeMeta>) => {
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<string, WorktreeMeta> = {
[parentId]: makeWorktreeMeta(),
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
}
const runtimeStore = {
...store,
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
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<string, WorktreeMeta> = {
[parentId]: makeWorktreeMeta({ instanceId: 'old-parent-instance' }),
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
}
const lineageById: Record<string, WorktreeLineage> = {
[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<WorktreeMeta>) => {
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<string, WorktreeMeta> = {
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
}
const lineageById: Record<string, WorktreeLineage> = {
[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<WorktreeMeta>) => {
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<string, WorktreeMeta> = {
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
}
const lineageById: Record<string, WorktreeLineage> = {
[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<WorktreeMeta>) => {
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<string, WorktreeMeta> = {
[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<WorktreeMeta>) => {
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<string, WorktreeMeta> = {
[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<WorktreeMeta>) => {
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<string, WorktreeMeta> = {
[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<WorktreeMeta>) => {
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<string, WorktreeMeta> = {
[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<WorktreeMeta>) => {
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()
+478 -6
View File
@@ -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<CreateWorktreeResult> {
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<string>([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<WorktreeLineageResolution> {
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<WorktreeLineageCandidate | null> {
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<void> {
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<RuntimeWorktreeScanResult> {
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<Map<string, ResolvedWorktree>> {
return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree]))
}
+11
View File
@@ -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' })
+27 -7
View File
@@ -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
}
+1
View File
@@ -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
+14
View File
@@ -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)
}
@@ -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(
@@ -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 }
}
@@ -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<string, unknown>, 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()
})
})
+67 -25
View File
@@ -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
})
})
}),
+7
View File
@@ -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<void>
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
listLineage: () => Promise<Record<string, WorktreeLineage>>
updateLineage: (args: {
worktreeId: string
parentWorktreeId?: string
noParent?: boolean
}) => Promise<WorktreeLineage | null>
persistSortOrder: (args: { orderedIds: string[] }) => Promise<void>
onChanged: (callback: (data: { repoId: string }) => void) => () => void
onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void) => () => void
+9
View File
@@ -460,6 +460,15 @@ const api = {
updates: Record<string, unknown>
}): Promise<unknown> => ipcRenderer.invoke('worktrees:updateMeta', args),
listLineage: (): Promise<Record<string, unknown>> =>
ipcRenderer.invoke('worktrees:listLineage'),
updateLineage: (args: {
worktreeId: string
parentWorktreeId?: string
noParent?: boolean
}): Promise<unknown> => ipcRenderer.invoke('worktrees:updateLineage', args),
persistSortOrder: (args: { orderedIds: string[] }): Promise<void> =>
ipcRenderer.invoke('worktrees:persistSortOrder', args),
+5
View File
@@ -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,
@@ -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 (
<div className="flex h-8 items-center justify-between px-2 gap-2">
<span className="px-2 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80 select-none">
@@ -114,6 +116,15 @@ const SidebarHeader = React.memo(function SidebarHeader() {
</ToggleGroup>
</div>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={showWorkspaceLineage}
onCheckedChange={(checked) => setShowWorkspaceLineage(Boolean(checked))}
onSelect={(e) => e.preventDefault()}
>
Nest child workspaces
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuRadioGroup
@@ -12,7 +12,8 @@ import {
CircleCheck,
CircleX,
Server,
ServerOff
ServerOff,
Workflow
} from 'lucide-react'
import StatusIndicator from './StatusIndicator'
import CacheTimer from './CacheTimer'
@@ -52,6 +53,8 @@ type WorktreeCardProps = {
isMultiSelected?: boolean
selectedWorktrees?: readonly Worktree[]
hideRepoBadge?: boolean
parentLabel?: string
lineageState?: 'valid' | 'missing'
onSelectionGesture?: (event: React.MouseEvent<HTMLDivElement>, worktreeId: string) => boolean
onContextMenuSelect?: (event: React.MouseEvent<HTMLDivElement>) => 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({
)}
<CacheTimer worktreeId={worktree.id} />
{parentLabel && (
<Badge
variant="outline"
className={cn(
'h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 gap-1 leading-none',
lineageState === 'missing'
? 'text-muted-foreground border-border bg-muted/40'
: 'text-muted-foreground border-border bg-accent/50'
)}
>
<Workflow className="size-2.5" />
<span className="max-w-[7rem] truncate">
{lineageState === 'missing' ? 'Missing parent' : `from ${parentLabel}`}
</span>
</Badge>
)}
</div>
{/* Meta section: Issue / hosted review / Comment
@@ -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 (
<>
<div
@@ -275,6 +317,45 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
{worktree.comment ? 'Edit Comment' : 'Add Comment'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={handleOpenParent}
disabled={isDeleting || !validParentWorktreeId}
>
<Workflow className="size-3.5" />
Open Parent Workspace
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleGroupUnderActive}
disabled={isDeleting || !canGroupUnderActive}
>
<Workflow className="size-3.5" />
Group under Active Workspace
</DropdownMenuItem>
{lineage && (
<DropdownMenuItem onSelect={handleRemoveParentLink} disabled={isDeleting}>
<Unlink className="size-3.5" />
Remove Parent Link
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
{isMultiContext && (
<>
<DropdownMenuItem
onSelect={handleGroupUnderActive}
disabled={deletingContext || !canGroupUnderActive}
>
<Workflow className="size-3.5" />
Group under Active Workspace
</DropdownMenuItem>
{hasAnyContextLineage && (
<DropdownMenuItem onSelect={handleRemoveParentLink} disabled={deletingContext}>
<Unlink className="size-3.5" />
Remove Parent Links
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<Tooltip>
@@ -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<Row, { type: 'item' }> }) {
if (row.depth <= 0) {
return null
}
const step = 18
const lineX = 10
const currentX = (row.depth - 1) * step + lineX
return (
<div className="pointer-events-none absolute inset-y-0 left-0 text-sidebar-border" aria-hidden>
{row.lineageTrail
.slice(0, -1)
.map((continues, index) =>
continues ? (
<span
key={index}
className="absolute top-0 bottom-0 w-px bg-current/80"
style={{ left: `${index * step + lineX}px` }}
/>
) : null
)}
<span
className="absolute top-0 w-px bg-current/80"
style={{
left: `${currentX}px`,
height: row.isLastLineageChild ? '50%' : '100%'
}}
/>
<span
className="absolute h-px bg-current/80"
style={{
left: `${currentX}px`,
top: '50%',
width: '12px'
}}
/>
</div>
)
}
function LineageDisclosure({
row,
onToggle
}: {
row: Extract<Row, { type: 'item' }>
onToggle: (event: React.MouseEvent<HTMLButtonElement>) => void
}) {
if (!row.lineageGroupKey || row.lineageChildCount <= 0) {
return null
}
const label = `${row.lineageCollapsed ? 'Show' : 'Hide'} ${row.lineageChildCount} child ${
row.lineageChildCount === 1 ? 'workspace' : 'workspaces'
}`
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-2 top-2 z-10 size-5 rounded-md bg-sidebar/80 text-muted-foreground opacity-80 shadow-sm ring-1 ring-sidebar-border/70 backdrop-blur-sm transition-colors hover:bg-sidebar-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100"
aria-label={label}
aria-expanded={!row.lineageCollapsed}
onClick={onToggle}
>
<ChevronRight
className={cn('size-3 transition-transform', !row.lineageCollapsed && 'rotate-90')}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{row.lineageCollapsed ? 'Show child workspaces' : 'Hide child workspaces'}
</TooltipContent>
</Tooltip>
)
}
type VirtualizedWorktreeViewportProps = {
rows: Row[]
activeWorktreeId: string | null
groupBy: 'none' | 'repo' | 'pr-status'
showWorkspaceLineage: boolean
toggleGroup: (key: string) => void
collapsedGroups: Set<string>
handleCreateForRepo: (repoId: string) => void
@@ -95,6 +177,8 @@ type VirtualizedWorktreeViewportProps = {
worktree: Worktree
) => readonly Worktree[]
repoMap: Map<string, Repo>
worktreeMap: Map<string, Worktree>
worktreeLineageById: Record<string, WorktreeLineage>
repoOrder: Map<string, number>
// 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<string>()
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<string>(),
repoOrder
repoOrder,
worktreeLineageById,
worktreeMap,
showWorkspaceLineage
).filter((r): r is Extract<Row, { type: 'item' }> => 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
/>
</div>
{row.repo ? (
{row.repo && groupBy === 'repo' ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -538,16 +666,36 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
className="absolute left-0 right-0"
style={{ transform: `translateY(${vItem.start}px)` }}
>
<WorktreeCard
worktree={row.worktree}
repo={row.repo}
isActive={activeWorktreeId === row.worktree.id}
isMultiSelected={selectedWorktreeIds.has(row.worktree.id)}
selectedWorktrees={selectedWorktrees}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={(event) => onContextMenuSelect(event, row.worktree)}
hideRepoBadge={groupBy === 'repo'}
/>
<div
className="group/lineage-row relative"
style={{ paddingLeft: row.depth > 0 ? `${row.depth * 18}px` : undefined }}
>
<LineageGuides row={row} />
<WorktreeCard
worktree={row.worktree}
repo={row.repo}
isActive={activeWorktreeId === row.worktree.id}
isMultiSelected={selectedWorktreeIds.has(row.worktree.id)}
selectedWorktrees={selectedWorktrees}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={(event) => onContextMenuSelect(event, row.worktree)}
hideRepoBadge={groupBy === 'repo'}
parentLabel={
row.depth > 0 && row.lineageState === 'valid' ? undefined : row.parentLabel
}
lineageState={row.lineageState}
/>
<LineageDisclosure
row={row}
onToggle={(event) => {
event.preventDefault()
event.stopPropagation()
if (row.lineageGroupKey) {
toggleGroup(row.lineageGroupKey)
}
}}
/>
</div>
</div>
)
})}
@@ -564,9 +712,11 @@ const WorktreeList = React.memo(function WorktreeList() {
const allWorktrees = useAllWorktrees()
const repoMap = useRepoMap()
const worktreeMap = useWorktreeMap()
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
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)
@@ -872,8 +1022,29 @@ const WorktreeList = React.memo(function WorktreeList() {
// Build flat row list for rendering
const rows: Row[] = useMemo(
() => buildRows(groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder),
[groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder]
() =>
buildRows(
groupBy,
worktrees,
repoMap,
prCache,
collapsedGroups,
repoOrder,
worktreeLineageById,
worktreeMap,
showWorkspaceLineage
),
[
groupBy,
worktrees,
repoMap,
prCache,
collapsedGroups,
repoOrder,
worktreeLineageById,
worktreeMap,
showWorkspaceLineage
]
)
// Why: rows.length alone can stay the same when items migrate between
// groups (e.g., PR cache loads on restart and a collapsed group absorbs
@@ -1054,6 +1225,7 @@ const WorktreeList = React.memo(function WorktreeList() {
rows={rows}
activeWorktreeId={selectedSidebarWorktreeId}
groupBy={groupBy}
showWorkspaceLineage={showWorkspaceLineage}
toggleGroup={toggleGroup}
collapsedGroups={collapsedGroups}
handleCreateForRepo={handleCreateForRepo}
@@ -1066,6 +1238,8 @@ const WorktreeList = React.memo(function WorktreeList() {
onSelectionGesture={updateSelectionForGesture}
onContextMenuSelect={selectForContextMenu}
repoMap={repoMap}
worktreeMap={worktreeMap}
worktreeLineageById={worktreeLineageById}
repoOrder={repoOrder}
allRepoIds={allRepoIds}
reorderRepos={(orderedIds) => {
@@ -1,8 +1,14 @@
/* eslint-disable max-lines -- Why: row-builder tests keep grouping, pinning, and lineage ordering cases together so expected row contracts stay comparable. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { buildRows, getPRGroupKey } from './worktree-list-groups'
import type { Repo, Worktree } from '../../../../shared/types'
import {
buildRows,
getLineageGroupKey,
getLineageRenderInfo,
getPRGroupKey
} from './worktree-list-groups'
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
const repo: Repo = {
id: 'repo-1',
@@ -171,6 +177,224 @@ describe('buildRows repo grouping order', () => {
})
})
describe('buildRows workspace lineage nesting', () => {
const parent: Worktree = {
...worktree,
id: 'wt-parent',
instanceId: 'parent-instance',
displayName: 'coordinator'
}
const child: Worktree = {
...worktree,
id: 'wt-child',
instanceId: 'child-instance',
displayName: 'worker'
}
const grandchild: Worktree = {
...worktree,
id: 'wt-grandchild',
instanceId: 'grandchild-instance',
displayName: 'nested-worker'
}
const lineage: WorktreeLineage = {
worktreeId: child.id,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parent.id,
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'terminal-context', confidence: 'inferred' },
createdAt: 1
}
const grandchildLineage: WorktreeLineage = {
worktreeId: grandchild.id,
worktreeInstanceId: 'grandchild-instance',
parentWorktreeId: child.id,
parentWorktreeInstanceId: 'child-instance',
origin: 'cli',
capture: { source: 'terminal-context', confidence: 'inferred' },
createdAt: 1
}
it('keeps lineage flat when nesting is off', () => {
const rows = buildRows(
'none',
[child, parent],
repoMap,
null,
new Set(),
undefined,
{ [child.id]: lineage },
new Map([
[parent.id, parent],
[child.id, child]
])
)
expect(rows[0]).toMatchObject({ type: 'item', worktree: { id: child.id } })
expect(rows[0]).not.toHaveProperty('parentLabel')
expect(rows[1]).toMatchObject({
type: 'item',
worktree: { id: parent.id }
})
})
it('places children directly under their parent when nesting is on', () => {
const rows = buildRows(
'none',
[child, parent],
repoMap,
null,
new Set(),
undefined,
{ [child.id]: lineage },
new Map([
[parent.id, parent],
[child.id, child]
]),
true
)
expect(rows[0]).toMatchObject({ type: 'item', worktree: { id: parent.id } })
expect(rows[1]).toMatchObject({
type: 'item',
worktree: { id: child.id },
depth: 1,
parentLabel: 'coordinator'
})
})
it('supports nested lineage chains beyond one level', () => {
const rows = buildRows(
'none',
[grandchild, child, parent],
repoMap,
null,
new Set(),
undefined,
{ [child.id]: lineage, [grandchild.id]: grandchildLineage },
new Map([
[parent.id, parent],
[child.id, child],
[grandchild.id, grandchild]
]),
true
)
expect(rows.map((row) => (row.type === 'item' ? row.worktree.id : row.key))).toEqual([
parent.id,
child.id,
grandchild.id
])
expect(rows[0]).toMatchObject({
type: 'item',
depth: 0,
lineageChildCount: 1,
lineageCollapsed: false
})
expect(rows[1]).toMatchObject({
type: 'item',
worktree: { id: child.id },
depth: 1,
lineageChildCount: 1
})
expect(rows[2]).toMatchObject({
type: 'item',
worktree: { id: grandchild.id },
depth: 2,
lineageChildCount: 0
})
})
it('collapses descendants under lineage parents', () => {
const rows = buildRows(
'none',
[grandchild, child, parent],
repoMap,
null,
new Set([getLineageGroupKey(parent.id)]),
undefined,
{ [child.id]: lineage, [grandchild.id]: grandchildLineage },
new Map([
[parent.id, parent],
[child.id, child],
[grandchild.id, grandchild]
]),
true
)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
type: 'item',
worktree: { id: parent.id },
lineageChildCount: 1,
lineageCollapsed: true
})
})
it('marks stale instance links as missing without creating a parent group', () => {
const staleLineage = { ...lineage, parentWorktreeInstanceId: 'old-parent-instance' }
const rows = buildRows(
'none',
[child],
repoMap,
null,
new Set(),
undefined,
{ [child.id]: staleLineage },
new Map([
[parent.id, parent],
[child.id, child]
]),
true
)
expect(rows[0]).toMatchObject({
type: 'item',
worktree: { id: child.id },
lineageState: 'missing'
})
})
it('marks stale instance links as missing for shared context-menu validation', () => {
const staleLineage = { ...lineage, parentWorktreeInstanceId: 'old-parent-instance' }
const info = getLineageRenderInfo(
child,
{ [child.id]: staleLineage },
new Map([
[parent.id, parent],
[child.id, child]
])
)
expect(info).toMatchObject({ state: 'missing' })
})
it('keeps pinned children in Pinned with parent context', () => {
const pinnedChild = { ...child, isPinned: true }
const rows = buildRows(
'none',
[parent, pinnedChild],
repoMap,
null,
new Set(),
undefined,
{ [child.id]: lineage },
new Map([
[parent.id, parent],
[child.id, pinnedChild]
]),
true
)
expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' })
expect(rows[1]).toMatchObject({
type: 'item',
worktree: { id: child.id },
parentLabel: 'coordinator'
})
})
})
describe('WorktreeList header styles', () => {
it('does not title-case workspace group labels', () => {
const source = readFileSync(
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */
import {
CircleCheckBig,
CircleDot,
@@ -8,7 +9,7 @@ import {
Pin
} from 'lucide-react'
import type React from 'react'
import type { Repo, Worktree } from '../../../../shared/types'
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
import { branchName } from '@/lib/git-utils'
export { branchName }
@@ -23,7 +24,19 @@ export type GroupHeaderRow = {
repo?: Repo
}
export type WorktreeRow = { type: 'item'; worktree: Worktree; repo: Repo | undefined }
export type WorktreeRow = {
type: 'item'
worktree: Worktree
repo: Repo | undefined
depth: number
lineageTrail: boolean[]
isLastLineageChild: boolean
lineageChildCount: number
lineageGroupKey?: string
lineageCollapsed?: boolean
parentLabel?: string
lineageState?: 'valid' | 'missing'
}
export type Row = GroupHeaderRow | WorktreeRow
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
@@ -81,6 +94,41 @@ export const ALL_GROUP_META = {
icon: LayoutList
} as const
export const MISSING_PARENT_GROUP_META = {
label: 'Missing parent'
} as const
export const LINEAGE_GROUP_PREFIX = 'lineage:'
export function getLineageGroupKey(worktreeId: string): string {
return `${LINEAGE_GROUP_PREFIX}${worktreeId}`
}
export type LineageRenderInfo =
| { state: 'none' }
| { state: 'valid'; lineage: WorktreeLineage; parent: Worktree }
| { state: 'missing'; lineage: WorktreeLineage }
export function getLineageRenderInfo(
worktree: Worktree,
lineageById: Record<string, WorktreeLineage>,
worktreeMap: Map<string, Worktree>
): LineageRenderInfo {
const lineage = lineageById[worktree.id]
if (!lineage) {
return { state: 'none' }
}
const parent = worktreeMap.get(lineage.parentWorktreeId)
if (
!parent ||
worktree.instanceId !== lineage.worktreeInstanceId ||
parent.instanceId !== lineage.parentWorktreeInstanceId
) {
return { state: 'missing', lineage }
}
return { state: 'valid', lineage, parent }
}
export function getPRGroupKey(
worktree: Worktree,
repoMap: Map<string, Repo>,
@@ -117,8 +165,11 @@ export function getPRGroupKey(
function emitPinnedGroup(
worktrees: Worktree[],
repoMap: Map<string, Repo>,
lineageById: Record<string, WorktreeLineage>,
worktreeMap: Map<string, Worktree>,
collapsedGroups: Set<string>,
result: Row[]
result: Row[],
showLineageContext: boolean
): Set<string> {
const pinned = worktrees.filter((w) => w.isPinned)
if (pinned.length === 0) {
@@ -134,13 +185,151 @@ function emitPinnedGroup(
icon: PINNED_GROUP_META.icon
})
if (!collapsedGroups.has(PINNED_GROUP_KEY)) {
for (const w of pinned) {
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
}
appendWorktreeRows(result, pinned, repoMap, lineageById, worktreeMap, {
nestLineage: false,
showLineageContext,
collapsedGroups
})
}
return new Set(pinned.map((w) => w.id))
}
function buildWorktreeRow(
worktree: Worktree,
repoMap: Map<string, Repo>,
lineageById: Record<string, WorktreeLineage>,
worktreeMap: Map<string, Worktree>,
showLineageContext: boolean,
depth: number,
lineageTrail: boolean[],
isLastLineageChild: boolean,
lineageChildCount: number,
lineageCollapsed: boolean
): WorktreeRow {
const lineage = showLineageContext
? getLineageRenderInfo(worktree, lineageById, worktreeMap)
: { state: 'none' as const }
return {
type: 'item',
worktree,
repo: repoMap.get(worktree.repoId),
depth,
lineageTrail,
isLastLineageChild,
lineageChildCount,
...(lineageChildCount > 0 ? { lineageGroupKey: getLineageGroupKey(worktree.id) } : {}),
...(lineageChildCount > 0 ? { lineageCollapsed } : {}),
...(lineage.state === 'valid'
? { parentLabel: lineage.parent.displayName, lineageState: 'valid' as const }
: lineage.state === 'missing'
? { parentLabel: MISSING_PARENT_GROUP_META.label, lineageState: 'missing' as const }
: {})
}
}
function appendWorktreeRows(
result: Row[],
worktrees: Worktree[],
repoMap: Map<string, Repo>,
lineageById: Record<string, WorktreeLineage>,
worktreeMap: Map<string, Worktree>,
options: {
nestLineage: boolean
showLineageContext: boolean
collapsedGroups: Set<string>
}
): void {
const { nestLineage, showLineageContext, collapsedGroups } = options
if (!nestLineage) {
for (const worktree of worktrees) {
result.push(
buildWorktreeRow(
worktree,
repoMap,
lineageById,
worktreeMap,
showLineageContext,
0,
[],
false,
0,
false
)
)
}
return
}
const visibleIds = new Set(worktrees.map((worktree) => worktree.id))
const childrenByParentId = new Map<string, Worktree[]>()
const childIds = new Set<string>()
for (const worktree of worktrees) {
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap)
if (lineage.state !== 'valid' || !visibleIds.has(lineage.parent.id)) {
continue
}
childIds.add(worktree.id)
const children = childrenByParentId.get(lineage.parent.id) ?? []
children.push(worktree)
childrenByParentId.set(lineage.parent.id, children)
}
const emitted = new Set<string>()
const emit = (
worktree: Worktree,
depth: number,
lineageTrail: boolean[],
isLastChild: boolean
): void => {
if (emitted.has(worktree.id)) {
return
}
const children = childrenByParentId.get(worktree.id) ?? []
const lineageGroupKey = getLineageGroupKey(worktree.id)
const lineageCollapsed = collapsedGroups.has(lineageGroupKey)
emitted.add(worktree.id)
result.push(
buildWorktreeRow(
worktree,
repoMap,
lineageById,
worktreeMap,
showLineageContext,
depth,
lineageTrail,
isLastChild,
children.length,
lineageCollapsed
)
)
if (lineageCollapsed) {
return
}
children.forEach((child, index) => {
emit(
child,
depth + 1,
[...lineageTrail, index < children.length - 1],
index === children.length - 1
)
})
}
const roots = worktrees.filter((worktree) => !childIds.has(worktree.id))
for (const [index, worktree] of roots.entries()) {
emit(worktree, 0, [], index === roots.length - 1)
}
if (roots.length === 0) {
for (const worktree of worktrees) {
if (!emitted.has(worktree.id)) {
// Why: malformed cyclic lineage should not hide every participant.
// Render any leftovers as roots rather than recursing forever.
emit(worktree, 0, [], true)
}
}
}
}
/**
* Build the flat row list consumed by the virtualizer.
* Extracted here to keep WorktreeList.tsx under the line-count lint limit.
@@ -151,11 +340,24 @@ export function buildRows(
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null,
collapsedGroups: Set<string>,
repoOrder?: Map<string, number>
repoOrder?: Map<string, number>,
lineageById: Record<string, WorktreeLineage> = {},
worktreeMap: Map<string, Worktree> = new Map(
worktrees.map((worktree) => [worktree.id, worktree])
),
nestLineage = false
): Row[] {
const result: Row[] = []
const pinnedIds = emitPinnedGroup(worktrees, repoMap, collapsedGroups, result)
const pinnedIds = emitPinnedGroup(
worktrees,
repoMap,
lineageById,
worktreeMap,
collapsedGroups,
result,
nestLineage
)
const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees
if (groupBy === 'none') {
@@ -175,9 +377,11 @@ export function buildRows(
return result
}
}
for (const w of unpinned) {
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
}
appendWorktreeRows(result, unpinned, repoMap, lineageById, worktreeMap, {
nestLineage,
showLineageContext: nestLineage,
collapsedGroups
})
return result
}
@@ -264,9 +468,11 @@ export function buildRows(
result.push(header)
if (!isCollapsed) {
for (const w of group.items) {
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
}
appendWorktreeRows(result, group.items, repoMap, lineageById, worktreeMap, {
nestLineage,
showLineageContext: nestLineage,
collapsedGroups
})
}
}
+1
View File
@@ -63,6 +63,7 @@ export function useIpcEvents(): void {
const state = useAppStore.getState()
const before = new Set((state.worktreesByRepo[data.repoId] ?? []).map((w) => w.id))
await state.fetchWorktrees(data.repoId)
await useAppStore.getState().fetchWorktreeLineage()
const afterState = useAppStore.getState()
const after = new Set((afterState.worktreesByRepo[data.repoId] ?? []).map((w) => w.id))
const removed: string[] = []
@@ -35,6 +35,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState
sidebarWidth: 280,
rightSidebarWidth: 350,
groupBy: 'none',
showWorkspaceLineage: false,
sortBy: 'name',
showActiveOnly: false,
hideDefaultBranchWorkspace: false,
+7 -1
View File
@@ -292,6 +292,8 @@ export type UISlice = {
clearOrcaHookTrustForRepo: (repoId: string) => void
groupBy: 'none' | 'repo' | 'pr-status'
setGroupBy: (g: UISlice['groupBy']) => void
showWorkspaceLineage: boolean
setShowWorkspaceLineage: (v: boolean) => void
sortBy: 'name' | 'smart' | 'recent' | 'repo'
setSortBy: (s: UISlice['sortBy']) => void
showActiveOnly: boolean
@@ -625,6 +627,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
set({ groupBy: g, collapsedGroups: new Set<string>() })
},
showWorkspaceLineage: false,
setShowWorkspaceLineage: (v) => set({ showWorkspaceLineage: v }),
sortBy: 'recent',
setSortBy: (s) => set({ sortBy: s }),
@@ -788,7 +793,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
s.rightSidebarWidth,
MAX_RIGHT_SIDEBAR_WIDTH
),
groupBy: ui.groupBy,
groupBy: (ui.groupBy as UISlice['groupBy'] | 'parent') === 'parent' ? 'repo' : ui.groupBy,
showWorkspaceLineage: ui.showWorkspaceLineage ?? false,
sortBy,
// Why: "Active only" is part of the user's sidebar working set, not a
// transient render detail. Restoring it on launch keeps the filtered
@@ -7,6 +7,7 @@ import type {
WorkspaceCreateTelemetrySource,
Worktree,
WorktreeBaseStatusEvent,
WorktreeLineage,
WorktreeRemoteBranchConflictEvent,
WorktreeMeta
} from '../../../../shared/types'
@@ -20,6 +21,7 @@ export type WorktreeDeleteState = {
export type WorktreeSlice = {
worktreesByRepo: Record<string, Worktree[]>
worktreeLineageById: Record<string, WorktreeLineage>
activeWorktreeId: string | null
deleteStateByWorktreeId: Record<string, WorktreeDeleteState>
baseStatusByWorktreeId: Record<string, WorktreeBaseStatusEvent>
@@ -62,6 +64,11 @@ export type WorktreeSlice = {
hasHydratedWorktreePurge: boolean
fetchWorktrees: (repoId: string) => Promise<void>
fetchAllWorktrees: () => Promise<void>
fetchWorktreeLineage: () => Promise<void>
updateWorktreeLineage: (
worktreeId: string,
args: { parentWorktreeId?: string; noParent?: boolean }
) => Promise<void>
createWorktree: (
repoId: string,
name: string,
@@ -31,6 +31,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
const candidate = next[index]
return (
worktree.id === candidate.id &&
worktree.instanceId === candidate.instanceId &&
worktree.repoId === candidate.repoId &&
worktree.path === candidate.path &&
worktree.head === candidate.head &&
@@ -70,6 +71,7 @@ function toVisibleTabType(contentType: string): WorkspaceVisibleTabType {
export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> = (set, get) => ({
worktreesByRepo: {},
worktreeLineageById: {},
activeWorktreeId: null,
deleteStateByWorktreeId: {},
baseStatusByWorktreeId: {},
@@ -176,6 +178,33 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
set({ hasHydratedWorktreePurge: true })
},
fetchWorktreeLineage: async () => {
try {
const lineage = await window.api.worktrees.listLineage()
set({ worktreeLineageById: lineage })
} catch (err) {
console.error('Failed to fetch worktree lineage:', err)
}
},
updateWorktreeLineage: async (worktreeId, args) => {
try {
const lineage = await window.api.worktrees.updateLineage({ worktreeId, ...args })
set((s) => {
const next = { ...s.worktreeLineageById }
if (lineage) {
next[worktreeId] = lineage
} else {
delete next[worktreeId]
}
return { worktreeLineageById: next, sortEpoch: s.sortEpoch + 1 }
})
} catch (err) {
console.error('Failed to update worktree lineage:', err)
void get().fetchWorktreeLineage()
}
},
updateWorktreeGitIdentity: (worktreeId, identity) => {
set((s) => {
const repoId = getRepoIdFromWorktreeId(worktreeId)
@@ -359,6 +388,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
const nextDeleteState = { ...s.deleteStateByWorktreeId }
delete nextDeleteState[worktreeId]
const nextLineage = { ...s.worktreeLineageById }
delete nextLineage[worktreeId]
// Clean up editor files belonging to this worktree
const newOpenFiles = s.openFiles.filter((f) => f.worktreeId !== worktreeId)
const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree }
@@ -456,6 +487,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
: s.lastVisitedAtByWorktreeId
return {
worktreesByRepo: next,
worktreeLineageById: nextLineage,
tabsByWorktree: nextTabs,
ptyIdsByTabId: nextPtyIdsByTabId,
runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId,
@@ -1043,6 +1075,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
return {
// Worktree-scoped terminal/tab state
worktreeLineageById: omitByWorktree(s.worktreeLineageById),
tabsByWorktree: omitByWorktree(s.tabsByWorktree),
terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId),
ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId),
+2
View File
@@ -294,6 +294,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
repos: [],
sparsePresetsByRepo: {},
worktreeMeta: {},
worktreeLineageById: {},
settings: getDefaultSettings(homedir),
ui: getDefaultUIState(),
githubCache: { pr: {}, issue: {} },
@@ -313,6 +314,7 @@ export function getDefaultUIState(): PersistedUIState {
sidebarWidth: 280,
rightSidebarWidth: 350,
groupBy: 'repo',
showWorkspaceLineage: false,
sortBy: 'recent',
showActiveOnly: false,
hideDefaultBranchWorkspace: false,
+9 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: shared type definitions for all runtime RPC methods live in one file for discoverability and import simplicity. */
import type { TerminalPaneLayoutNode } from './types'
import type { TerminalPaneLayoutNode, WorktreeLineage, WorktreeLineageWarning } from './types'
import type { BrowserSessionProfile, GitWorktreeInfo, Repo } from './types'
import type {
RuntimeMarkdownReadTabResult,
@@ -294,6 +294,7 @@ export type RuntimeWorktreeStatus = 'active' | 'working' | 'permission' | 'done'
export type RuntimeWorktreeRecord = {
id: string
instanceId?: string
repoId: string
path: string
branch: string
@@ -303,6 +304,13 @@ export type RuntimeWorktreeRecord = {
comment: string
}
export type RuntimeWorktreeCreateResult = {
worktree: RuntimeWorktreeRecord
lineage: WorktreeLineage | null
warnings: WorktreeLineageWarning[]
warning?: string
}
export type RuntimeWorktreePsResult = {
worktrees: RuntimeWorktreePsSummary[]
totalCount: number
+45
View File
@@ -108,6 +108,7 @@ export type GitWorktreeInfo = {
// ─── Worktree (app-level, enriched) ──────────────────────────────────
export type Worktree = {
id: string // `${repoId}::${path}`
instanceId?: string
repoId: string
displayName: string
comment: string
@@ -158,6 +159,8 @@ export type GitPushTarget = {
// ─── Worktree metadata (persisted user-authored fields only) ─────────
export type WorktreeMeta = {
/** Immutable per-workspace-instance ID used to reject stale lineage after path reuse. */
instanceId?: string
displayName: string
comment: string
linkedIssue: number | null
@@ -186,6 +189,44 @@ export type WorktreeMeta = {
diffComments?: DiffComment[]
}
export type WorktreeLineageOrigin = 'orchestration' | 'cli' | 'manual'
export type WorktreeLineageCaptureConfidence = 'explicit' | 'inferred'
export type WorktreeLineageCaptureSource =
| 'explicit-cli-flag'
| 'terminal-context'
| 'orchestration-context'
| 'manual-action'
export type WorktreeLineageCapture = {
source: WorktreeLineageCaptureSource
confidence: WorktreeLineageCaptureConfidence
}
export type WorktreeLineage = {
worktreeId: string
worktreeInstanceId: string
parentWorktreeId: string
parentWorktreeInstanceId: string
origin: WorktreeLineageOrigin
capture: WorktreeLineageCapture
orchestrationRunId?: string
taskId?: string
coordinatorHandle?: string
createdByTerminalHandle?: string
createdAt: number
}
export type WorktreeLineageWarningCode =
| 'LINEAGE_PARENT_CONTEXT_MISSING'
| 'LINEAGE_PARENT_CONTEXT_CONFLICT'
| 'LINEAGE_PARENT_INSTANCE_STALE'
export type WorktreeLineageWarning = {
code: WorktreeLineageWarningCode
message: string
details?: Record<string, unknown>
}
// ─── Diff line comments ──────────────────────────────────────────────
// Why: users leave review notes on specific lines of the modified side of
// a diff so they can be handed back to an AI agent (pasted into a terminal
@@ -955,6 +996,8 @@ export type CreateWorktreeArgs = {
export type CreateWorktreeResult = {
worktree: Worktree
lineage?: WorktreeLineage | null
warnings?: WorktreeLineageWarning[]
setup?: WorktreeSetupLaunch
warning?: string
initialBaseStatus?: WorktreeBaseStatusEvent
@@ -1554,6 +1597,7 @@ export type PersistedUIState = {
sidebarWidth: number
rightSidebarWidth: number
groupBy: 'none' | 'repo' | 'pr-status'
showWorkspaceLineage?: boolean
sortBy: 'name' | 'smart' | 'recent' | 'repo'
showActiveOnly: boolean
/** Hide the repo's original checked-out branch from workspace navigation
@@ -1741,6 +1785,7 @@ export type PersistedState = {
* presets are managed from the new-workspace composer and repo settings. */
sparsePresetsByRepo: Record<string, SparsePreset[]>
worktreeMeta: Record<string, WorktreeMeta>
worktreeLineageById: Record<string, WorktreeLineage>
settings: GlobalSettings
ui: PersistedUIState
githubCache: {