Files
orca/src/main/runtime/rpc/methods/worktree.ts
T
Neil 3e6a62d192 review: reassert host truth on unchanged polls; content-address snapshots
Client — the `changed` gate meant an unchanged poll skipped setWorktrees /
setLastKnownWorktrees / setCachedWorktrees, so optimistic local edits
(togglePin, handleDeleteWorktree's failure re-add) and the #8498 cache guard
were no longer repaired while the host catalog was stable. The gate bought
nothing: setCachedWorktrees is an in-memory Map write and areWorktreeListsEqual
already ran every poll, so the steady state still short-circuits on array
identity. All wire savings are unaffected. admit() now just returns the
confirmed rows and HostScreen applies them exactly as it did pre-PR.

Also on the client:
- a stale response from a superseded client/host no longer clears the token the
  current client/host just established
- discriminate on `worktrees` rather than on `'unchanged' in response`, so a
  future catalog field named `unchanged` can't reclassify a full response
- useRef over useMemo for the snapshot client; React may discard memoized values
- hoist WORKTREE_PS_FULL_LIMIT so the truncates-at-200 rationale travels with it

Host — replace the per-limit snapshot cache with a content-addressed id (ETag
semantics). Ownership lives in the id, so concurrent clients, differing limits,
and runtime restarts are correct by construction; this drops the LRU, the
eviction policy, the per-runtime WeakMap, and the retention of up to 8 full
catalogs. The remaining cache is a pure memo: because ids derive from content,
dropping or thrashing it costs CPU and nothing else. Keeping the memo also
keeps the measured steady-state cost — hashing every poll instead measured
2.24ms vs 0.75ms for the compare on a 310KB catalog.

Verified: mobile 2784 passed / 3 skipped, src/main/runtime/rpc 1064 passed,
node + mobile typechecks, oxlint, oxfmt, max-lines ratchet.
2026-07-31 02:48:39 -07:00

289 lines
11 KiB
TypeScript

import {
finishAutomationWorkspaceProvenanceRequest,
releaseAutomationWorkspaceProvenanceRequest,
resolveAutomationWorkspaceProvenance
} from '../../../automations/workspace-provenance'
import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance'
import { defineMethod, type RpcMethod } from '../core'
import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot'
import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation'
import {
WorktreeCreate,
WorktreeDetectedListParams,
WorktreeActivate,
WorktreeForceDeleteBranch,
WorktreeListParams,
WorktreePrefetchCreateBase,
WorktreePsParams,
WorktreeRemove,
WorktreeResolveMrBase,
WorktreeResolvePrBase,
WorktreeSelector,
WorktreeSet,
WorktreeSortOrder,
WorktreeTeardownMissingTerminalsParams
} from './worktree-schemas'
export const WORKTREE_METHODS: RpcMethod[] = [
defineMethod({
name: 'worktree.ps',
params: WorktreePsParams,
handler: async (params, { runtime }) => {
const result = await runtime.getWorktreePs(params.limit)
// Why: callers that never send the field get the byte-exact legacy response.
if (params.afterSnapshotId === undefined) {
return result
}
return resolveWorktreeCatalogSnapshot(result, params.afterSnapshotId)
}
}),
defineMethod({
name: 'worktree.list',
params: WorktreeListParams,
handler: async (params, { runtime }) => runtime.listManagedWorktrees(params.repo, params.limit)
}),
defineMethod({
name: 'worktree.detectedList',
params: WorktreeDetectedListParams,
handler: async (params, { runtime }) => runtime.listDetectedManagedWorktrees(params.repo)
}),
defineMethod({
name: 'worktree.teardownMissingTerminals',
params: WorktreeTeardownMissingTerminalsParams,
handler: async (params, { runtime }) =>
runtime.teardownMissingManagedWorktreeTerminals(
params.repo,
params.worktreeIds,
params.connectionId
)
}),
defineMethod({
name: 'worktree.lineageList',
params: null,
handler: async (_params, { runtime }) => ({
lineage: await runtime.listWorktreeLineage(),
workspaceLineage: await runtime.listWorkspaceLineage()
})
}),
defineMethod({
name: 'worktree.show',
params: WorktreeSelector,
handler: async (params, { runtime }) => ({
worktree: await runtime.showManagedWorktree(params.worktree)
})
}),
defineMethod({
name: 'worktree.sleep',
params: WorktreeSelector,
handler: async (params, { runtime }) => runtime.sleepManagedWorktree(params.worktree)
}),
defineMethod({
name: 'worktree.activate',
params: WorktreeActivate,
handler: async (params, { runtime, clientKind }) =>
// Why: clientKind ('mobile'|'runtime') scopes the host-renderer slept-agent
// wake to phones so web/desktop activation behavior is unchanged.
runtime.activateManagedWorktree(params.worktree, {
notifyClients: params.notifyClients !== false,
clientKind,
navigation: resolveRuntimeNavigationTarget({
navigation: params.navigation,
notifyClients: params.notifyClients,
clientKind
})
})
}),
defineMethod({
name: 'worktree.create',
params: WorktreeCreate,
handler: async (params, { runtime }) =>
// Why: a mobile create interrupted by a connection migration is retried with
// the same clientMutationId; dedupe so the host returns the in-flight/created
// worktree instead of spawning a duplicate. No key (desktop/CLI) runs plainly.
runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, async () => {
const repo = await runtime.showRepo(params.repo)
const automationProvenance = resolveAutomationWorkspaceProvenance({
authority: runtime,
repoSelector: params.repo,
repo,
request: params.automationProvenanceRequest
})
// Why: provenance tokens are reserved before creation so retries can recover,
// but failed create attempts must release the reservation for a safe retry.
try {
const result = await runtime.createManagedWorktree({
repoSelector: params.repo,
name: params.name ?? '',
baseBranch: params.baseBranch,
compareBaseRef: params.compareBaseRef,
branchNameOverride: params.branchNameOverride,
linkedIssue: params.linkedIssue,
linkedPR: params.linkedPR,
linkedLinearIssue: params.linkedLinearIssue,
linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey,
linkedGitLabMR: params.linkedGitLabMR,
linkedGitLabIssue: params.linkedGitLabIssue,
linkedBitbucketPR: params.linkedBitbucketPR,
linkedAzureDevOpsPR: params.linkedAzureDevOpsPR,
linkedGiteaPR: params.linkedGiteaPR,
linkedWorkItem: params.linkedWorkItem,
linkedTaskSourceContext: params.linkedTaskSourceContext,
comment: params.comment,
displayName: params.displayName,
telemetrySource: params.telemetrySource,
workspaceStatus: params.workspaceStatus,
manualOrder: params.manualOrder,
sparseCheckout: params.sparseCheckout,
pushTarget: params.pushTarget,
runHooks: params.runHooks === true,
activate: params.activate === true,
setupDecision: params.setupDecision,
createdWithAgent: params.createdWithAgent ?? params.startupAgent,
automationProvenance,
cliProvenance: buildCliWorkspaceProvenance(params.cliProvenanceRequest, {
startupAgent: params.startupAgent ?? params.createdWithAgent,
createdAt: Date.now()
}),
startup: params.startupCommand
? {
command: params.startupCommand,
...(params.startupEnv ? { env: params.startupEnv } : {}),
...(params.startupLaunchConfig
? { launchConfig: params.startupLaunchConfig }
: {}),
...(params.startupCommandDelivery
? { startupCommandDelivery: params.startupCommandDelivery }
: {})
}
: undefined,
...(params.startupAgent ? { startupAgent: params.startupAgent } : {}),
...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}),
startupDraft: params.startupDraft,
lineage: {
parentWorkspace: params.parentWorkspace,
envParentWorkspace: params.envParentWorkspace,
parentWorktree: params.parentWorktree,
...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}),
noParent: params.noParent === true,
callerTerminalHandle: params.callerTerminalHandle,
orchestrationContext: params.orchestrationContext
}
})
finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest)
// Why: agent callers need a stable dispatch target without traversing
// terminal-list layout duplicates after creating the worktree.
return params.startupAgent && result.startupTerminal?.handle
? { ...result, agentTerminalHandle: result.startupTerminal.handle }
: result
} catch (error) {
releaseAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest)
throw error
}
})
}),
defineMethod({
name: 'worktree.prefetchCreateBase',
params: WorktreePrefetchCreateBase,
handler: async (params, { runtime }) => {
await runtime.prefetchManagedWorktreeCreateBase({
repoSelector: params.repo,
baseBranch: params.baseBranch
})
return null
}
}),
defineMethod({
name: 'worktree.set',
params: WorktreeSet,
handler: async (params, { runtime }) => ({
worktree: await runtime.updateManagedWorktreeMeta(params.worktree, {
displayName: params.displayName,
linkedIssue: params.linkedIssue,
linkedPR: params.linkedPR,
linkedLinearIssue: params.linkedLinearIssue,
linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey,
linkedGitLabMR: params.linkedGitLabMR,
linkedGitLabIssue: params.linkedGitLabIssue,
linkedBitbucketPR: params.linkedBitbucketPR,
linkedAzureDevOpsPR: params.linkedAzureDevOpsPR,
linkedGiteaPR: params.linkedGiteaPR,
linkedWorkItem: params.linkedWorkItem,
linkedTaskSourceContext: params.linkedTaskSourceContext,
comment: params.comment,
isArchived: params.isArchived,
isUnread: params.isUnread,
isPinned: params.isPinned,
sortOrder: params.sortOrder,
manualOrder: params.manualOrder,
lastActivityAt: params.lastActivityAt,
createdAt: params.createdAt,
sparseDirectories: params.sparseDirectories,
sparseBaseRef: params.sparseBaseRef,
sparsePresetId: params.sparsePresetId,
baseRef: params.baseRef,
workspaceStatus: params.workspaceStatus,
pushTarget: params.pushTarget,
diffComments: params.diffComments,
mobileDiffReview: params.mobileDiffReview,
lineage:
params.parentWorktree || params.noParent === true
? {
parentWorktree: params.parentWorktree,
noParent: params.noParent === true
}
: undefined
} as Parameters<typeof runtime.updateManagedWorktreeMeta>[1])
})
}),
defineMethod({
name: 'worktree.persistSortOrder',
params: WorktreeSortOrder,
handler: async (params, { runtime }) =>
runtime.persistManagedWorktreeSortOrder(params.orderedIds)
}),
defineMethod({
name: 'worktree.resolvePrBase',
params: WorktreeResolvePrBase,
handler: async (params, { runtime }) =>
runtime.resolveManagedPrBase({
repoSelector: params.repo,
prNumber: params.prNumber,
headRefName: params.headRefName,
baseRefName: params.baseRefName,
isCrossRepository: params.isCrossRepository
})
}),
defineMethod({
name: 'worktree.resolveMrBase',
params: WorktreeResolveMrBase,
handler: async (params, { runtime }) =>
runtime.resolveManagedMrBase({
repoSelector: params.repo,
mrIid: params.mrIid,
sourceBranch: params.sourceBranch,
targetBranch: params.targetBranch,
isCrossRepository: params.isCrossRepository
})
}),
defineMethod({
name: 'worktree.rm',
params: WorktreeRemove,
handler: async (params, { runtime }) => {
const result = await runtime.removeManagedWorktree(
params.worktree,
params.force === true,
params.runHooks === true
)
return { removed: true, ...result }
}
}),
defineMethod({
name: 'worktree.forceDeleteBranch',
params: WorktreeForceDeleteBranch,
handler: async (params, { runtime }) =>
runtime.forceDeletePreservedBranch(params.worktree, params.branchName, params.expectedHead)
})
]