Files
orca/src/main/runtime/rpc/methods/worktree.ts
T
Neil a8085cc851 fix(worktree): block removal when the archive hook fails
A repo's orca.yaml archive hook is the user's last chance to save work off a
checkout Orca is about to delete. A failed hook was logged as advisory and
stepped over, so the removal went ahead with nothing archived — and the caller
could still be told it succeeded.

The hook is now a blocking precondition, evaluated while the checkout, its Git
registration, its agents and Orca's ownership evidence are all still intact: it
sits ahead of the registration re-read, the lock/dirty preflights, stopPtys()
and removeWorktree in every orchestrator that runs it.

Failure is typed (worktree_archive_hook_failed) and carries the worktree path,
outcome, exit code where one was observed, and the hook's output. unverifiable
stays distinct from exited, so loss of contact is never read as a pass. The
waiver rides its own field at every layer and is never implied by --force, which
already carries the PTY-stop waiver; when used, the waived failure comes back on
result.archiveHookOverride rather than being swallowed.

worktree.archive-failure-blocking.v1 is advertised so an integration can tell
"accepts --run-hooks" from "safely propagates a failing hook" without risking the
data loss to find out. The runtime's SSH path cannot run a hook at all, so rather
than delete with the archive step silently skipped it refuses — waivable like
every other refusal here. #18563 retires that gate by making the path run the
hook for real.

Stacked on #20559, which makes a timed-out hook report honestly; without it a
hook that traps SIGTERM and exits 0 would defeat this gate.

Fixes #19334
2026-09-15 01:10:58 -07:00

272 lines
10 KiB
TypeScript

import {
finishAutomationWorkspaceProvenanceRequest,
releaseAutomationWorkspaceProvenanceRequest,
resolveAutomationWorkspaceProvenance
} from '../../../automations/workspace-provenance'
import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance'
import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display-name-provenance'
import { defineMethod } from '../core'
import { buildManagedWorktreeCreateArgs } from './worktree-create-args'
import { resolvePairedCallerHostId } from './paired-caller-host-id'
import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation'
import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context'
import { WorktreeCreate, WorktreePrefetchCreateBase } from './worktree-create-schemas'
import {
WorktreeActivate,
WorktreeForceDeleteBranch,
WorktreeRemove,
WorktreeResolveMrBase,
WorktreeResolvePrBase,
WorktreeSelector,
WorktreeSet,
WorktreeSortOrder,
WorktreeTeardownMissingTerminalsParams
} from './worktree-schemas'
import { WORKTREE_CATALOG_METHODS } from './worktree-catalog-methods'
export const WORKTREE_METHODS = [
...WORKTREE_CATALOG_METHODS,
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, context) =>
// 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.
context.runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, async () => {
const { runtime } = context
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(
buildManagedWorktreeCreateArgs(
params,
{
automationProvenance,
cliProvenance: buildCliWorkspaceProvenance(params.cliProvenanceRequest, {
startupAgent: params.startupAgent ?? params.createdWithAgent,
createdAt: Date.now()
}),
creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context)
},
context.clientKind ? { clientKind: context.clientKind } : {}
)
)
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,
...(params.displayName !== undefined
? { displayNameIsPinned: displayNameUpdatePinsLabel(params.displayName) }
: {}),
linkedIssue: params.linkedIssue,
linkedPR: params.linkedPR,
suppressedGitHubPR: params.suppressedGitHubPR,
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 }) => {
// Translate a paired client's runtime-local host spelling before host-qualified reads.
let resolvedHostId = resolvePairedCallerHostId(
() => runtime.listRepos(),
params.worktree,
params.hostId
)
// Older mobile clients omit hostId, so resolve through the ambiguity gate
// before pinning removal. An ambiguous selector still fails closed: two
// hosts own the id and an unqualified client cannot say which it meant.
if (!resolvedHostId) {
try {
resolvedHostId = (await runtime.showManagedWorktree(params.worktree)).hostId
if (!resolvedHostId) {
throw new Error('worktree.rm could not resolve the workspace host')
}
} catch (error) {
// 'selector_not_found' is not a failure to attribute — Git simply no
// longer lists the path. A delete legitimately arrives in that state and
// `removeManagedWorktree` handles it, so a stale workspace stays
// deletable by a client that sends no host. Anything else propagates.
if (!(error instanceof Error) || error.message !== 'selector_not_found') {
throw error
}
}
}
const result = await runtime.removeManagedWorktree(params.worktree, {
force: params.force === true,
runHooks: params.runHooks === true,
allowUnverifiedPtyStop: params.allowUnverifiedPtyStop === true,
allowFailedArchiveHook: params.allowFailedArchiveHook === true,
...(resolvedHostId ? { hostId: resolvedHostId } : {})
})
return { removed: true, ...result }
}
}),
defineMethod({
name: 'worktree.forceDeleteBranch',
params: WorktreeForceDeleteBranch,
handler: async (params, { runtime }) => {
const hostId = resolvePairedCallerHostId(
() => runtime.listRepos(),
params.worktree,
params.hostId
)
return hostId
? runtime.forceDeletePreservedBranch(
params.worktree,
params.branchName,
params.expectedHead,
hostId
)
: runtime.forceDeletePreservedBranch(
params.worktree,
params.branchName,
params.expectedHead
)
}
})
]