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
This commit is contained in:
Neil
2026-09-15 01:10:58 -07:00
parent 37a5b278b3
commit a8085cc851
52 changed files with 2152 additions and 394 deletions
@@ -0,0 +1,526 @@
/**
* Real-repo verification for #19334 — run with:
* node config/scripts/archive-hook-removal-repro.mjs
*
* Requires a prior `build:cli` and `build:electron-vite`; it drives the BUILT CLI against the
* BUILT headless runtime, so it proves the shipped artifacts rather than the test harness.
*: a failed archive hook must BLOCK a destructive
* worktree removal, and the checkout, its git registration and its files must all survive.
*
* Boots the BUILT headless runtime (`out/main/index.js --serve`), pairs the BUILT CLI to it,
* and drives `orca worktree rm` end to end against real git worktrees on disk.
*/
import { spawn, spawnSync } from 'node:child_process'
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
const projectDir = resolve(import.meta.dirname, '../..')
const serveEntry = join(projectDir, 'out', 'main', 'index.js')
const cliEntry = join(projectDir, 'out', 'cli', 'index.js')
const PORT = 6900 + Math.floor(Math.random() * 400)
const READY_TIMEOUT_MS = 180_000
const control = mkdtempSync(join(tmpdir(), 'agh-control-'))
const modeFile = join(control, 'mode')
const ranFile = join(control, 'ran')
const setMode = (m) => writeFileSync(modeFile, m)
const hookRuns = () => (existsSync(ranFile) ? readFileSync(ranFile, 'utf8').trim().split('\n') : [])
let failures = 0
const out = (s) => process.stdout.write(`${s}\n`)
const banner = (s) => out(`\n${'='.repeat(78)}\n${s}\n${'='.repeat(78)}`)
function check(label, ok, detail = '') {
out(` ${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` -- ${detail}` : ''}`)
if (!ok) {
failures++
}
}
let pairingCode = null
/** Run the real CLI against the booted server. Returns the raw process result. */
function cli(args, { json = true } = {}) {
return spawnSync(
process.execPath,
[cliEntry, ...args, '--pairing-code', pairingCode, ...(json ? ['--json'] : [])],
{ encoding: 'utf8', shell: false }
)
}
/** Run the CLI and require success, returning result payload. */
function ok(args) {
const r = cli(args)
const parsed = parseJsonLine(r)
if (!parsed) {
throw new Error(`orca ${args.join(' ')} produced no JSON:\n${r.stdout}\n${r.stderr}`)
}
if (parsed.ok === false) {
throw new Error(`orca ${args.join(' ')} failed: ${parsed.error?.code} ${parsed.error?.message}`)
}
return parsed.result
}
/** The CLI pretty-prints one JSON document to stdout. */
function parseJsonLine(r) {
const text = (r.stdout ?? '').trim()
const start = text.indexOf('{')
if (start === -1) {
return null
}
try {
return JSON.parse(text.slice(start))
} catch {
return null
}
}
function git(cwd, ...args) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
if (r.status !== 0) {
throw new Error(`git ${args.join(' ')}: ${r.stderr || r.stdout}`)
}
return r.stdout
}
const ARCHIVE_HOOK = `echo "[archive-hook] running in $PWD"
echo "$PWD" >> ${JSON.stringify(ranFile).slice(1, -1)}
mode=$(cat ${JSON.stringify(modeFile).slice(1, -1)})
case "$mode" in
ok) echo "[archive-hook] archived OK"; exit 0 ;;
fail) echo "[archive-hook] backup target unreachable" >&2; exit 23 ;;
signal) echo "[archive-hook] losing the execution host now"; kill -KILL $$ ;;
esac
echo "unknown mode $mode" >&2; exit 99
`
/** A throwaway git repo with one commit; optionally an orca.yaml archive hook. */
function seedGitRepo(label, withHook, githubSlug) {
const dir = mkdtempSync(join(tmpdir(), `agh-repo-${label}-`))
writeFileSync(join(dir, 'README.md'), `# ${label}\n`)
if (withHook) {
writeFileSync(
join(dir, 'orca.yaml'),
`scripts:\n archive: |\n${ARCHIVE_HOOK.split('\n')
.map((l) => ` ${l}`)
.join('\n')}\n`
)
}
git(dir, 'init', '-b', 'main')
git(dir, 'config', 'user.email', 'verify@orca.test')
git(dir, 'config', 'user.name', 'Archive Gate Verify')
if (githubSlug) {
git(dir, 'remote', 'add', 'origin', `https://github.com/agh-owner/${githubSlug}.git`)
}
git(dir, 'add', '-A')
git(dir, 'commit', '-m', 'seed')
return dir
}
function waitForReady(child) {
return new Promise((res, rej) => {
let buffered = ''
let serverErr = ''
const timer = setTimeout(
() => rej(new Error(`no ready payload in ${READY_TIMEOUT_MS}ms\n${serverErr}`)),
READY_TIMEOUT_MS
)
child.stderr.setEncoding('utf8')
child.stderr.on('data', (c) => {
serverErr += c
})
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
buffered += chunk
for (const line of buffered.split('\n')) {
if (!line.startsWith('{')) {
continue
}
try {
const p = JSON.parse(line)
if (p.type === 'orca_server_ready') {
clearTimeout(timer)
res(p)
return
}
} catch {
/* partial */
}
}
})
child.on('exit', (code) => {
clearTimeout(timer)
rej(new Error(`server exited ${code} before ready:\n${serverErr}`))
})
})
}
/** Filesystem + git truth about a worktree, read directly rather than through Orca. */
function evidence(repoPath, wtPath) {
const ls = spawnSync('ls', ['-la', wtPath], { encoding: 'utf8' })
const list = spawnSync('git', ['worktree', 'list'], { cwd: repoPath, encoding: 'utf8' })
return {
dirExists: existsSync(wtPath),
fileExists: existsSync(join(wtPath, 'PRECIOUS.txt')),
fileBody: existsSync(join(wtPath, 'PRECIOUS.txt'))
? readFileSync(join(wtPath, 'PRECIOUS.txt'), 'utf8').trim()
: null,
registered: (list.stdout ?? '').includes(wtPath),
ls: (ls.stdout ?? '').trim(),
worktreeList: (list.stdout ?? '').trim()
}
}
function showEvidence(e) {
out(' --- ls -la <worktree> ---')
out(
e.ls
.split('\n')
.map((l) => ` ${l}`)
.join('\n')
)
out(' --- git worktree list (in the repo) ---')
out(
e.worktreeList
.split('\n')
.map((l) => ` ${l}`)
.join('\n')
)
}
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'agh-userdata-'))
out(`booting headless runtime on port ${PORT}, userData ${userDataDir}`)
const child = spawn(
'npx',
[
'electron',
serveEntry,
'--serve',
'--serve-port',
String(PORT),
'--serve-json',
`--user-data-dir=${userDataDir}`
],
{
cwd: projectDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }
}
)
const created = []
try {
const ready = await waitForReady(child)
pairingCode = new URL(ready.pairing.url).searchParams.get('code')
out(`ready: ${ready.advertisedEndpoint}`)
// ---------------------------------------------------------------- setup
const hookRepoPath = seedGitRepo('hooked', true)
const folderProjectSlug = `agh-folder-proof-${randomBytes(3).toString('hex')}`
const bareRepoPath = seedGitRepo('nohook', false, folderProjectSlug)
const hookRepo = ok(['repo', 'add', '--path', hookRepoPath]).repo
const bareRepo = ok(['repo', 'add', '--path', bareRepoPath]).repo
out(`repo with archive hook: ${hookRepoPath} (${hookRepo.id})`)
out(`repo without archive hook: ${bareRepoPath} (${bareRepo.id})`)
const makeWorktree = (repo, repoPath, name) => {
const wt = ok([
'worktree',
'create',
'--repo',
`id:${repo.id}`,
'--name',
name,
'--setup',
'skip'
]).worktree
created.push(wt)
const unarchivedBody = `unarchived work for ${name}`
writeFileSync(join(wt.path, 'PRECIOUS.txt'), `${unarchivedBody}\n`)
return { ...wt, repoPath, unarchivedBody }
}
// ============================================================ SCENARIO 1
banner('SCENARIO 1 — archive hook exits 23: removal MUST be refused, nothing deleted')
setMode('fail')
const wt1 = makeWorktree(hookRepo, hookRepoPath, `gate-fail-${randomBytes(3).toString('hex')}`)
out(`worktree: ${wt1.path}`)
const before = evidence(wt1.repoPath, wt1.path)
out('\n$ orca worktree rm --worktree <id> --run-hooks (human output)')
const human = cli(['worktree', 'rm', '--worktree', wt1.id, '--run-hooks'], { json: false })
out(` exit code: ${human.status}`)
out(' --- stdout ---')
out(
(human.stdout ?? '')
.trimEnd()
.split('\n')
.map((l) => ` ${l}`)
.join('\n')
)
out(' --- stderr ---')
out(
(human.stderr ?? '')
.trimEnd()
.split('\n')
.map((l) => ` ${l}`)
.join('\n')
)
out(
'\n$ orca worktree rm --worktree <id> --force --run-hooks --json (--force must NOT waive)'
)
const forced = cli(['worktree', 'rm', '--worktree', wt1.id, '--force', '--run-hooks'])
const forcedJson = parseJsonLine(forced)
out(` exit code: ${forced.status}`)
out(` ${JSON.stringify(forcedJson)}`)
const after1 = evidence(wt1.repoPath, wt1.path)
showEvidence(after1)
check('CLI exits non-zero', human.status !== 0, `got ${human.status}`)
check(
'human stderr names the archive hook',
/Archive hook failed for worktree/.test(human.stderr ?? '')
)
check('--force also refused (non-zero)', forced.status !== 0, `got ${forced.status}`)
check(
'typed error code',
forcedJson?.error?.code === 'worktree_archive_hook_failed',
JSON.stringify(forcedJson?.error?.code)
)
check(
"error data outcome is 'exited'",
forcedJson?.error?.data?.outcome === 'exited',
JSON.stringify(forcedJson?.error?.data)
)
check('error data carries exitCode 23', forcedJson?.error?.data?.exitCode === 23)
check('checkout directory still exists', after1.dirExists)
// Assert the CONTENTS, not just the path: a file that survived as an empty stub would prove
// nothing about the work the archive hook was supposed to rescue.
check(
'unarchived file PRECIOUS.txt survives with its contents',
after1.fileExists && after1.fileBody === wt1.unarchivedBody,
`exists=${after1.fileExists} body=${JSON.stringify(after1.fileBody)}`
)
check('git worktree registration survives', after1.registered)
check(
'nothing changed vs. before the attempt',
before.dirExists === after1.dirExists && before.registered === after1.registered
)
const shown = ok(['worktree', 'show', '--worktree', wt1.id]).worktree
check('Orca still resolves the worktree', shown?.id === wt1.id)
check(
'the hook really ran (twice: plain + --force)',
hookRuns().length >= 2,
`runs=${hookRuns().length}`
)
// The checkout is dirty (untracked PRECIOUS.txt). The plain run reported the ARCHIVE failure,
// not the dirty-preflight failure, so the gate is evaluated before that preflight.
check(
'archive gate precedes the dirty preflight (dirty checkout, archive error reported)',
/Archive hook failed/.test(human.stderr ?? '') &&
!/\?\? PRECIOUS\.txt/.test(human.stderr ?? '')
)
// ============================================================ SCENARIO 2
banner('SCENARIO 2 — --allow-failed-archive-hook: removal proceeds, waiver recorded')
// --force here waives only the DIRTY preflight (PRECIOUS.txt is untracked on purpose);
// scenario 1 already proved it does not waive the archive gate.
const waived = cli([
'worktree',
'rm',
'--worktree',
wt1.id,
'--force',
'--run-hooks',
'--allow-failed-archive-hook'
])
const waivedJson = parseJsonLine(waived)
out(` exit code: ${waived.status}`)
out(` ${JSON.stringify(waivedJson)}`)
const after2 = evidence(wt1.repoPath, wt1.path)
out(` checkout still on disk: ${after2.dirExists}`)
out(
` --- git worktree list ---\n${after2.worktreeList
.split('\n')
.map((l) => ` ${l}`)
.join('\n')}`
)
check('override exits zero', waived.status === 0, `got ${waived.status}`)
check('removal reported', waivedJson?.result?.removed === true)
check('checkout is GONE', !after2.dirExists)
check('git registration is gone', !after2.registered)
check(
'archiveHookOverride recorded',
waivedJson?.result?.archiveHookOverride?.overridden === true,
JSON.stringify(waivedJson?.result?.archiveHookOverride)
)
check(
'override records exit 23 / exited',
waivedJson?.result?.archiveHookOverride?.exitCode === 23 &&
waivedJson?.result?.archiveHookOverride?.outcome === 'exited'
)
// ============================================================ SCENARIO 3
banner('SCENARIO 3 — archive hook exits 0: removal proceeds')
setMode('ok')
const wt3 = makeWorktree(hookRepo, hookRepoPath, `gate-ok-${randomBytes(3).toString('hex')}`)
out(`worktree: ${wt3.path}`)
const okRm = cli(['worktree', 'rm', '--worktree', wt3.id, '--force', '--run-hooks'])
const okJson = parseJsonLine(okRm)
out(` exit code: ${okRm.status}`)
out(` ${JSON.stringify(okJson)}`)
const after3 = evidence(wt3.repoPath, wt3.path)
check('exits zero', okRm.status === 0)
check('checkout deleted', !after3.dirExists)
check('git registration gone', !after3.registered)
check(
'no archiveHookOverride on a clean run',
okJson?.result?.archiveHookOverride === undefined
)
// ============================================================ SCENARIO 4
banner('SCENARIO 4 — no archive hook configured: removal proceeds unchanged')
const wt4 = makeWorktree(
bareRepo,
bareRepoPath,
`gate-nohook-${randomBytes(3).toString('hex')}`
)
out(`worktree: ${wt4.path}`)
const runsBefore = hookRuns().length
const noHook = cli(['worktree', 'rm', '--worktree', wt4.id, '--force', '--run-hooks'])
const noHookJson = parseJsonLine(noHook)
out(` exit code: ${noHook.status}`)
out(` ${JSON.stringify(noHookJson)}`)
const after4 = evidence(wt4.repoPath, wt4.path)
check('exits zero', noHook.status === 0)
check('checkout deleted', !after4.dirExists)
check('no hook was run', hookRuns().length === runsBefore)
// ============================================================ SCENARIO 5
banner('SCENARIO 5 — hook never reports an exit (killed): must BLOCK as `unverifiable`')
setMode('signal')
const wt5 = makeWorktree(hookRepo, hookRepoPath, `gate-unver-${randomBytes(3).toString('hex')}`)
out(`worktree: ${wt5.path}`)
const unver = cli(['worktree', 'rm', '--worktree', wt5.id, '--run-hooks'])
const unverJson = parseJsonLine(unver)
out(` exit code: ${unver.status}`)
out(` ${JSON.stringify(unverJson)}`)
const after5 = evidence(wt5.repoPath, wt5.path)
showEvidence(after5)
check('blocked (non-zero)', unver.status !== 0, `got ${unver.status}`)
check('typed error code', unverJson?.error?.code === 'worktree_archive_hook_failed')
check(
"outcome is 'unverifiable', NOT 'exited'",
unverJson?.error?.data?.outcome === 'unverifiable',
JSON.stringify(unverJson?.error?.data)
)
check(
'exit code is WITHHELD (never read as a pass)',
unverJson?.error?.data?.exitCode === undefined
)
check('checkout survives', after5.dirExists && after5.fileExists)
check('git registration survives', after5.registered)
// clean up scenario 5 with the waiver so the temp dirs go away
setMode('ok')
cli(['worktree', 'rm', '--worktree', wt5.id, '--force'])
// ============================================================ SCENARIO 6
banner('SCENARIO 6 — folder workspace removal (the boundary that runs no hook) is unchanged')
const folderDir = mkdtempSync(join(tmpdir(), 'agh-folder-'))
mkdirSync(join(folderDir, 'src'))
writeFileSync(join(folderDir, 'src', 'app.txt'), 'folder workspace content\n')
// A folder workspace is imported against an existing project identity, so anchor it on the
// hookless repo's GitHub-derived project.
const folderProjectId = `github:agh-owner/${folderProjectSlug}`
ok([
'project',
'setup-existing-folder',
'--project',
folderProjectId,
'--host',
'local',
'--path',
folderDir,
'--kind',
'folder'
])
const folderRepo = (ok(['repo', 'list']).repos ?? []).find((r) => r.path === folderDir) ?? null
out(`folder repo: ${folderDir} (${folderRepo?.id}) kind=${folderRepo?.kind}`)
check('registered repo kind is folder', folderRepo?.kind === 'folder', String(folderRepo?.kind))
// The project ROOT of a folder project is not deletable (pre-existing rule, unrelated to the
// gate); the deletable folder workspace is a child created under it.
const folderRoot = ok(['worktree', 'show', '--worktree', `path:${folderDir}`]).worktree
const rootRm = cli(['worktree', 'rm', '--worktree', folderRoot.id, '--force', '--run-hooks'])
out(
` root refusal (unchanged): exit ${rootRm.status} ${parseJsonLine(rootRm)?.error?.code} -- ${parseJsonLine(rootRm)?.error?.message}`
)
check(
'folder project root still refuses for its own reason, not the archive gate',
rootRm.status !== 0 && parseJsonLine(rootRm)?.error?.code !== 'worktree_archive_hook_failed'
)
const folderChild = ok([
'worktree',
'create',
'--repo',
`id:${folderRepo.id}`,
'--name',
'agh-folder-child',
'--setup',
'skip'
]).worktree
out(`folder workspace: ${folderChild.id}`)
const runsBeforeFolder = hookRuns().length
out(`\n$ orca worktree rm --worktree <folder workspace> --force --run-hooks`)
const folderRm = cli(['worktree', 'rm', '--worktree', folderChild.id, '--force', '--run-hooks'])
const folderJson = parseJsonLine(folderRm)
out(` exit code: ${folderRm.status}`)
out(` ${JSON.stringify(folderJson)}`)
const stillThere = cli(['worktree', 'show', '--worktree', folderChild.id])
check(
'folder workspace removal exits zero',
folderRm.status === 0,
`${folderRm.status} ${folderRm.stderr}`
)
check('folder removal ran no archive hook', hookRuns().length === runsBeforeFolder)
check(
'folder contents left on disk (forget, not delete)',
existsSync(join(folderDir, 'src', 'app.txt'))
)
check(
'folder workspace is deregistered',
stillThere.status !== 0 && parseJsonLine(stillThere)?.error?.code === 'selector_not_found'
)
rmSync(folderDir, { recursive: true, force: true })
banner(failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`)
} catch (error) {
out(`\nHARNESS ERROR: ${error instanceof Error ? error.stack : String(error)}`)
failures++
} finally {
for (const wt of created) {
if (existsSync(wt.path)) {
cli(['worktree', 'rm', '--worktree', wt.id, '--force'])
rmSync(dirname(wt.path), { recursive: true, force: true })
}
}
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGTERM')
await Promise.race([
new Promise((r) => child.on('exit', r)),
new Promise((r) => setTimeout(r, 15_000))
])
child.kill('SIGKILL')
}
rmSync(userDataDir, { recursive: true, force: true })
}
process.exitCode = failures === 0 ? 0 : 1
}
setMode('ok')
main()
@@ -0,0 +1,37 @@
import {
formatArchiveHookOverride,
type ArchiveHookOverride
} from '../../shared/worktree/archive-hook-removal-gate'
type HookWarningResult = {
warning?: string
archiveHookOverride?: ArchiveHookOverride
}
type PreservedBranchResult = {
preservedBranch?: {
branchName: string
}
}
export function printHookWarning(result: HookWarningResult, json: boolean): void {
if (json) {
return
}
if (result.warning) {
console.error(`warning: ${result.warning}`)
}
// Why (#19334): a waived archive-hook failure is the one case where Orca deleted a checkout
// whose archive step did not succeed. It has to stay visible in human output.
if (result.archiveHookOverride) {
console.error(`warning: ${formatArchiveHookOverride(result.archiveHookOverride)}`)
}
}
export function printPreservedBranchWarning(result: PreservedBranchResult, json: boolean): void {
if (!json && result.preservedBranch) {
console.error(
`warning: local branch "${result.preservedBranch.branchName}" was kept because Git could not safely delete it`
)
}
}
+13 -25
View File
@@ -6,6 +6,7 @@ import type {
RuntimeWorktreeRemoveResult
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { printHookWarning, printPreservedBranchWarning } from './worktree-removal-warnings'
import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format'
import {
annotateOmittedHostScope,
@@ -38,30 +39,6 @@ import {
} from './worktree-create-parent-selector'
import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link'
type HookWarningResult = {
warning?: string
}
type PreservedBranchResult = {
preservedBranch?: {
branchName: string
}
}
function printHookWarning(result: HookWarningResult, json: boolean): void {
if (!json && result.warning) {
console.error(`warning: ${result.warning}`)
}
}
function printPreservedBranchWarning(result: PreservedBranchResult, json: boolean): void {
if (!json && result.preservedBranch) {
console.error(
`warning: local branch "${result.preservedBranch.branchName}" was kept because Git could not safely delete it`
)
}
}
function assertParentWorktreeFlagsCompatible(flags: Map<string, string | boolean>): void {
if (flags.has('parent-worktree') && flags.get('no-parent') === true) {
throw new RuntimeClientError(
@@ -305,13 +282,24 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
'Orca cannot tell which host owns this workspace. Refresh projects and try again.'
)
}
// Why (#19334): the waiver only ever applies to a hook that ran, so without --run-hooks it
// silently does nothing. Rejecting it beats letting someone believe they waived something.
if (flags.get('allow-failed-archive-hook') === true && flags.get('run-hooks') !== true) {
throw new RuntimeClientError(
'invalid_argument',
'--allow-failed-archive-hook waives a FAILED archive hook, but without --run-hooks no hook runs at all. Pass --run-hooks too, or drop the waiver.'
)
}
const result = await client.call<RuntimeWorktreeRemoveResult>('worktree.rm', {
worktree,
hostId,
force: flags.get('force') === true,
// Why (#11960): --force is explicit here, so it may also waive PTY-stop proof.
allowUnverifiedPtyStop: flags.get('force') === true,
runHooks: flags.get('run-hooks') === true
runHooks: flags.get('run-hooks') === true,
// Why (#19334): deliberately NOT coupled to --force, which above already waives PTY-stop
// proof. Waiving a failed archive hook is a separate decision about the user's data.
allowFailedArchiveHook: flags.get('allow-failed-archive-hook') === true
})
printHookWarning(result.result, json)
printPreservedBranchWarning(result.result, json)
+77
View File
@@ -135,6 +135,83 @@ describe('command aliases dispatch to the canonical handler', () => {
}
})
// #19334: a failed archive hook blocks removal, so the CLI must exit non-zero rather than
// report a delete that did not happen — and the waiver must ride its own flag, never --force.
it('exits non-zero when worktree removal is refused by a failed archive hook', async () => {
queueFixtures(callMock, okFixture('req_show', { worktree: { hostId: 'local' } }))
callMock.mockRejectedValueOnce(
Object.assign(new Error('Archive hook failed for worktree: /tmp/wt — exited 23.'), {
code: 'worktree_archive_hook_failed'
})
)
const priorExitCode = process.exitCode
try {
await main(
['worktree', 'rm', '--worktree', 'id:wt-1', '--force', '--run-hooks', '--json'],
'/tmp/repo'
)
expect(process.exitCode).toBe(1)
expect(callMock).toHaveBeenNthCalledWith(
2,
'worktree.rm',
expect.objectContaining({
runHooks: true,
allowFailedArchiveHook: false
})
)
} finally {
process.exitCode = priorExitCode
}
})
// #19334 S4: the waiver only applies to a hook that ran, so alone it silently does nothing.
it('rejects the archive-hook waiver without --run-hooks instead of ignoring it', async () => {
queueFixtures(callMock, okFixture('req_show', { worktree: { hostId: 'local' } }))
const priorExitCode = process.exitCode
try {
await main(
['worktree', 'rm', '--worktree', 'id:wt-1', '--allow-failed-archive-hook', '--json'],
'/tmp/repo'
)
expect(process.exitCode).toBe(1)
// The removal must never have been attempted.
expect(callMock).not.toHaveBeenCalledWith('worktree.rm', expect.anything())
} finally {
process.exitCode = priorExitCode
}
})
it('forwards the explicit archive-hook waiver on worktree rm', async () => {
queueFixtures(
callMock,
okFixture('req_show', { worktree: { hostId: 'local' } }),
okFixture('req', { removed: true })
)
await main(
[
'worktree',
'rm',
'--worktree',
'id:wt-1',
'--run-hooks',
'--allow-failed-archive-hook',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(
2,
'worktree.rm',
expect.objectContaining({ runHooks: true, allowFailedArchiveHook: true })
)
})
it('still runs `terminal focus` after the handler de-duplication', async () => {
queueFixtures(callMock, okFixture('req', { focus: { ok: true } }))
+1 -1
View File
@@ -52,7 +52,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' orca worktree show --worktree <selector> [--json]',
' orca worktree current [--json]',
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]',
' orca worktree ps [--limit <n>] [--json]',
' orca file open <path> [--worktree <selector>] [--json]',
' orca file diff <path> [--staged] [--worktree <selector>] [--json]',
+5 -2
View File
@@ -172,10 +172,13 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
],
destructive: true,
summary: 'Remove a worktree from Orca and git',
usage: 'orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks'],
usage:
'orca worktree rm --worktree <selector> [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks', 'allow-failed-archive-hook'],
notes: [
'Repo-defined orca.yaml archive hooks are skipped unless --run-hooks is passed.',
'With --run-hooks, a failed archive hook blocks the removal: nothing is stopped, deleted or deregistered, and the command exits non-zero with error code worktree_archive_hook_failed. --force does not waive this.',
'Pass --allow-failed-archive-hook to delete anyway after the hook has run and failed; the waived failure is reported back on result.archiveHookOverride. It requires --run-hooks and is rejected without it, because with no hook running there is no failure to waive.',
'For Git worktrees, removal also attempts to delete the checked-out local branch, with or without --force. Orca retains branches it knows predated the worktree and any branch whose changes it cannot prove are already merged.'
]
},
@@ -14,6 +14,13 @@ import {
} from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { mockKnownFeatureWorktree } from './worktrees-test-fixtures'
import {
ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
asArchiveHookRefusal,
type WorktreeArchiveHookFailedError
} from '../../shared/worktree/archive-hook-removal-gate'
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import type { RemoveWorktreeArgs } from './worktrees/ipc-context-schemas'
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
vi.mock('electron', async () =>
@@ -98,6 +105,25 @@ vi.mock('../runtime/worktree-teardown', async () =>
)
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
// The shared IPC surface types every handler as returning `unknown`; removal's contract is
// narrower, and #19334's whole point is that a caller can name and branch on it.
async function removeWorktreeViaIpc(args: RemoveWorktreeArgs): Promise<RemoveWorktreeResult> {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry types every handler as `(...) => unknown`, so this is the only place the real `worktrees:remove` return shape can be named; the production caller in worktree-ipc.ts declares the same type.
return (await handlers['worktrees:remove'](null, args)) as RemoveWorktreeResult
}
/** Narrows through the exported error class — the same branch a real caller would write. */
async function expectArchiveHookRefusal(
args: RemoveWorktreeArgs
): Promise<WorktreeArchiveHookFailedError> {
try {
await removeWorktreeViaIpc(args)
} catch (error) {
return asArchiveHookRefusal(error)
}
throw new Error(`expected removal of ${args.worktreeId} to be refused by the archive hook`)
}
describe('registerWorktreeHandlers', () => {
let runtimeStub: WorktreeRuntimeStub
@@ -443,7 +469,8 @@ describe('registerWorktreeHandlers', () => {
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', true)
})
it('continues SSH worktree removal when the archive hook fails', async () => {
// Was "continues SSH worktree removal when the archive hook fails" (#19334): it now refuses.
it('refuses SSH worktree removal when the remote archive hook exits non-zero', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@@ -453,7 +480,6 @@ describe('registerWorktreeHandlers', () => {
connectionId: 'conn-1',
worktreeBaseRef: null
}
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const provider = {
listWorktrees: vi.fn().mockResolvedValue([
{
@@ -492,21 +518,18 @@ describe('registerWorktreeHandlers', () => {
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'exit 7' } })
try {
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-ssh::/remote/feature-wt'
})
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[hooks] archive hook failed for /remote/feature-wt:',
expect.stringContaining('archive hook exited 7')
)
} finally {
consoleErrorSpy.mockRestore()
}
const refusal = await expectArchiveHookRefusal({
worktreeId: 'repo-ssh::/remote/feature-wt'
})
expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
expect(refusal.data).toMatchObject({ outcome: 'exited', exitCode: 7 })
expect(provider.worktreeIsClean).not.toHaveBeenCalled()
expect(provider.removeWorktree).not.toHaveBeenCalled()
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('continues SSH worktree removal when archive hook execution rejects', async () => {
it('does not read a lost SSH connection as an archive hook that passed', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@@ -516,7 +539,6 @@ describe('registerWorktreeHandlers', () => {
connectionId: 'conn-1',
worktreeBaseRef: null
}
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const provider = {
listWorktrees: vi.fn().mockResolvedValue([
{
@@ -550,18 +572,15 @@ describe('registerWorktreeHandlers', () => {
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
try {
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-ssh::/remote/feature-wt'
})
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[hooks] archive hook failed for /remote/feature-wt:',
'relay disconnected'
)
} finally {
consoleErrorSpy.mockRestore()
}
const refusal = await expectArchiveHookRefusal({
worktreeId: 'repo-ssh::/remote/feature-wt'
})
// Loss of contact is `unverifiable`, never evidence the hook succeeded.
expect(refusal.data).toMatchObject({ outcome: 'unverifiable' })
expect(refusal.data.exitCode).toBeUndefined()
expect(provider.removeWorktree).not.toHaveBeenCalled()
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('uses cmd.exe for archive hooks on Windows-like SSH worktree paths', async () => {
@@ -681,4 +700,116 @@ describe('registerWorktreeHandlers', () => {
expect(provider.execNonInteractive).not.toHaveBeenCalled()
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
})
// Regression cover for #19334: a failed archive hook is a blocking precondition, not an advisory.
it('refuses removal and mutates nothing when the local archive hook exits 23', async () => {
mockKnownFeatureWorktree()
removeWorktreeMock.mockResolvedValue(undefined)
getEffectiveHooksMock.mockReturnValue({
scripts: { archive: 'echo archived' }
})
runHookMock.mockResolvedValue({
success: false,
output: 'backup target unreachable',
exitCode: 23
})
const refusal = await expectArchiveHookRefusal({
worktreeId: 'repo-1::/workspace/feature-wt'
})
expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
expect(refusal.data).toEqual({
worktreePath: '/workspace/feature-wt',
outcome: 'exited',
exitCode: 23,
output: 'backup target unreachable'
})
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
expect(assertWorktreeCleanForRemovalMock).not.toHaveBeenCalled()
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('classifies a local archive hook that never reported an exit as unverifiable', async () => {
mockKnownFeatureWorktree()
getEffectiveHooksMock.mockReturnValue({
scripts: { archive: 'echo archived' }
})
runHookMock.mockResolvedValue({
success: false,
output: 'Hook timed out after 120000ms.'
})
const refusal = await expectArchiveHookRefusal({
worktreeId: 'repo-1::/workspace/feature-wt'
})
expect(refusal.data).toEqual({
worktreePath: '/workspace/feature-wt',
outcome: 'unverifiable',
output: 'Hook timed out after 120000ms.'
})
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('removes and records the waiver when a failed archive hook is explicitly overridden', async () => {
mockKnownFeatureWorktree()
removeWorktreeMock.mockResolvedValue({})
getEffectiveHooksMock.mockReturnValue({
scripts: { archive: 'echo archived' }
})
runHookMock.mockResolvedValue({
success: false,
output: 'boom',
exitCode: 23
})
const result = await removeWorktreeViaIpc({
worktreeId: 'repo-1::/workspace/feature-wt',
allowFailedArchiveHook: true
})
expect(result.archiveHookOverride).toEqual({
worktreePath: '/workspace/feature-wt',
outcome: 'exited',
exitCode: 23,
output: 'boom',
overridden: true
})
expect(removeWorktreeMock).toHaveBeenCalled()
})
// The folder-workspace path runs no archive hook at all (no Git removal step), so the gate has
// nothing to evaluate there. Pinned so a future hook added to that path is a deliberate change.
it('removes a folder workspace without consulting the archive hook', async () => {
const repo = {
id: 'repo-folder',
path: '/workspace/folder-project',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder' as const,
worktreeBaseRef: null
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'exit 23' } })
runHookMock.mockResolvedValue({
success: false,
output: 'boom',
exitCode: 23
})
const result = await removeWorktreeViaIpc({
worktreeId: 'repo-folder::/workspace/folder-project/nested'
})
expect(result).toEqual({})
expect(runHookMock).not.toHaveBeenCalled()
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(store.removeWorktreeMeta).toHaveBeenCalled()
})
})
@@ -21,6 +21,9 @@ export type RemoveWorktreeArgs = {
/** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */
allowUnverifiedPtyStop?: boolean
skipArchive?: boolean
/** Explicit waiver for a FAILED archive hook (#19334). Distinct from `skipArchive`, which
* never runs the hook at all, and never implied by `force`. */
allowFailedArchiveHook?: boolean
snapshotPruneBatchId?: string
}
@@ -12,6 +12,8 @@ import { isPrunableGitFileWorktree } from '../../../worktree-prunable-git-file'
import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety'
import { removeStaleLocalWorktreeRegistration } from '../../../local-worktree-removal-recovery'
import { runHook } from '../../../hooks'
import type { ArchiveHookOverride } from '../../../../shared/worktree/archive-hook-removal-gate'
import { gateWorktreeRemovalOnArchiveHook } from '../../../worktree-archive-hook-gate'
import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation'
import {
cleanupUnusedWorktreePushTargetRemote,
@@ -126,10 +128,18 @@ export async function executeWorktreeRemoval(
return removalResult ?? {}
}
// No connectionId override here, deliberately: this path derives its host from the repo row
// (`getRepoExecutionHostId` in register-worktree-removal-handlers) and resolves its provider, git
// options, listing and dispatch from `repo.connectionId` alone. Passing a different owner to the
// hook reader would read one host's orca.yaml while running the other host's git. The runtime's
// SSH path is the one that carries a route owner separate from the row, and it passes it.
const hooks = await getArchiveHooksForRemoval(repo)
const archiveScript = hooks?.scripts.archive
// Precondition, not an advisory (#19334): both branches below stop PTYs and delete the
// checkout, so a hook failure has to throw here — before either is reached.
let archiveHookOverride: ArchiveHookOverride | undefined
if (archiveScript && !args.skipArchive) {
// Why the branch on connectionId: this block is shared by both flows, so a hardcoded
// 'remote' would file every local archive hook under the SSH breakdown.
@@ -146,38 +156,40 @@ export async function executeWorktreeRemoval(
undefined,
localWorktreeGitOptions
)
if (!result.success) {
console.error(`[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output)
}
archiveHookOverride = gateWorktreeRemovalOnArchiveHook({
worktreePath: canonicalWorktreePath,
result,
allowFailure: args.allowFailedArchiveHook === true
})
}
)
}
const remoteConnectionId = repo.connectionId ?? undefined
if (remoteConnectionId) {
return removeRegisteredRemoteWorktree(
context,
args,
repo,
repoId,
canonicalWorktreePath,
removalHostId,
registeredWorktree,
removedPushTarget,
provider!,
deleteBranch
)
}
return removeRegisteredLocalWorktree(
context,
args,
repo,
repoId,
canonicalWorktreePath,
removalHostId,
removedPushTarget,
localWorktreeGitOptions,
hasLocalWorktreeGitOptions,
deleteBranch
)
const result = remoteConnectionId
? await removeRegisteredRemoteWorktree(
context,
args,
repo,
repoId,
canonicalWorktreePath,
removalHostId,
registeredWorktree,
removedPushTarget,
provider!,
deleteBranch
)
: await removeRegisteredLocalWorktree(
context,
args,
repo,
repoId,
canonicalWorktreePath,
removalHostId,
removedPushTarget,
localWorktreeGitOptions,
hasLocalWorktreeGitOptions,
deleteBranch
)
return archiveHookOverride ? { ...result, archiveHookOverride } : result
}
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
const { getSshFilesystemProviderMock, getEffectiveHooksMock } = vi.hoisted(() => ({
getSshFilesystemProviderMock: vi.fn(),
getEffectiveHooksMock: vi.fn()
}))
vi.mock('../../../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock
}))
vi.mock('../../../hooks', () => ({ getEffectiveHooks: getEffectiveHooksMock }))
import { getArchiveHooksForRemoval } from './worktree-archive-hook'
const REMOTE_REPO: Repo = {
id: 'r',
path: '/home/orca/repo',
displayName: 'r',
badgeColor: '#000',
addedAt: 0
}
// Why (#19334): a worktree row can name its owner only as `executionHostId: 'ssh:<target>'`, leaving
// `repo.connectionId` null. Resolving hooks off the row alone then reads THIS machine's disk for a
// repo that lives on an SSH host — the committed archive hook goes unseen and the removal proceeds
// as though none were configured, which is the bug the gate exists to stop.
describe('getArchiveHooksForRemoval owner resolution', () => {
beforeEach(() => {
vi.clearAllMocks()
getSshFilesystemProviderMock.mockReturnValue(undefined)
getEffectiveHooksMock.mockReturnValue(null)
})
it('asks the execution host when only the route knows the connection', async () => {
await getArchiveHooksForRemoval(REMOTE_REPO, 'ssh-target')
expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('ssh-target')
// The local reader must never answer for a repo that lives on another host.
expect(getEffectiveHooksMock).not.toHaveBeenCalled()
})
it('falls back to the repo row when the caller names no owner', async () => {
await getArchiveHooksForRemoval({ ...REMOTE_REPO, connectionId: 'row-connection' })
expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('row-connection')
expect(getEffectiveHooksMock).not.toHaveBeenCalled()
})
it('reads locally only when neither names a connection', async () => {
await getArchiveHooksForRemoval({ ...REMOTE_REPO, path: '/local/repo' })
expect(getSshFilesystemProviderMock).not.toHaveBeenCalled()
expect(getEffectiveHooksMock).toHaveBeenCalled()
})
// Known limitation, pinned so it is a decision rather than a surprise: the relay rewrites a
// non-numeric error code to -32000, so a missing orca.yaml and an unreachable host arrive
// identically. Both answer "no hook", which lets the removal proceed. Reporting them apart needs
// a provider contract that returns absence as a successful outcome — tracked in #20196.
it('answers "no hook" when the host cannot be read, missing or unreachable alike', async () => {
getSshFilesystemProviderMock.mockReturnValue({
readFile: vi.fn().mockRejectedValue(
Object.assign(new Error('transport closed'), {
code: -32000
})
)
})
await expect(getArchiveHooksForRemoval(REMOTE_REPO, 'ssh-target')).resolves.toEqual(null)
})
})
@@ -7,15 +7,34 @@ import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-disp
import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch'
import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths'
import { getSetupRunnerEnvVars } from '../../../setup-hook-env-vars'
import type { ArchiveHookRunResult } from '../../../../shared/worktree/archive-hook-removal-gate'
const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000
export async function getArchiveHooksForRemoval(repo: Repo): Promise<OrcaHooks | null> {
if (!repo.connectionId) {
/**
* Resolve the archive hook against the host that owns the worktree.
*
* A failed read is answered as "no hook", which is a known limitation rather than a judgement: a
* missing `orca.yaml` is indistinguishable from an unreachable one here, because the relay rewrites
* a non-numeric error code to `-32000` (`src/relay/dispatcher-rpc-routing.ts`), so nothing survives
* to tell ENOENT from a transport failure. Reporting it as unreadable fired on every SSH repo that
* simply has no orca.yaml; blocking on it would refuse those deletes outright. Distinguishing the
* two needs a provider contract that reports absence as a successful outcome — tracked in #20196.
*
* @param connectionId Overrides `repo.connectionId`, which answers null for a row that names its
* owner only as `executionHostId: 'ssh:<target>'`. Callers holding a resolved removal route must
* pass it, or an SSH-hosted repo is read on the local disk and its archive hook goes unseen.
*/
export async function getArchiveHooksForRemoval(
repo: Repo,
connectionId?: string
): Promise<OrcaHooks | null> {
const owner = connectionId ?? repo.connectionId
if (!owner) {
return getEffectiveHooks(repo)
}
const fsProvider = getSshFilesystemProvider(repo.connectionId)
const fsProvider = getSshFilesystemProvider(owner)
if (!fsProvider) {
return getEffectiveHooksFromConfig(repo, null)
}
@@ -33,7 +52,7 @@ export async function runRemoteArchiveHook(
repo: Repo,
worktreePath: string,
script: string
): Promise<{ success: boolean; output: string }> {
): Promise<ArchiveHookRunResult> {
if (!repo.connectionId) {
return { success: true, output: '' }
}
@@ -70,8 +89,15 @@ export async function runRemoteArchiveHook(
.join('\n')
.trim()
// Why (#19334): a spawn error or timeout means the host never reported an exit for this run, so
// the code is withheld and the gate classifies the failure `unverifiable` rather than `exited`.
const observedExit =
!result.spawnError && !result.timedOut && typeof result.exitCode === 'number'
? result.exitCode
: undefined
return {
success: !result.spawnError && !result.timedOut && result.exitCode === 0,
output
success: observedExit === 0,
output,
...(observedExit !== undefined ? { exitCode: observedExit } : {})
}
}
@@ -8,14 +8,21 @@ export type WorktreeRemovalInFlight = {
}
export function getWorktreeRemovalOptionsKey(
args: Pick<RemoveWorktreeArgs, 'force' | 'allowUnverifiedPtyStop' | 'skipArchive'>
args: Pick<
RemoveWorktreeArgs,
'force' | 'allowUnverifiedPtyStop' | 'skipArchive' | 'allowFailedArchiveHook'
>
): string {
const forceKey = args.force === true ? 'force' : 'normal'
const archiveKey = args.skipArchive === true ? 'skip-archive' : 'run-archive'
// Why: a Force Delete retry must not coalesce onto the in-flight attempt that
// just failed the PTY gate — it would inherit that failure instead of retrying.
const ptyKey = args.allowUnverifiedPtyStop === true ? 'allow-unverified-pty' : 'require-pty-stop'
return `${forceKey}:${archiveKey}:${ptyKey}`
// Same reason for the archive waiver: a retry that waives the failed hook must not coalesce
// onto the in-flight attempt that is about to refuse on it.
const archiveFailureKey =
args.allowFailedArchiveHook === true ? 'allow-failed-archive' : 'require-archive'
return `${forceKey}:${archiveKey}:${ptyKey}:${archiveFailureKey}`
}
export function getWorktreeRemovalInFlightKey(
@@ -7,7 +7,10 @@ import {
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup'
import { getRuntimeWorktreeRemovalOptionsKey } from './runtime-worktree-selection'
import {
getRuntimeWorktreeRemovalOptionsKey,
type RemoveManagedWorktreeOptions
} from './runtime-worktree-selection'
import { withWorktreeSpan } from '../observability/instrumentation'
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
import { resolveWorktreeRemovalRoute } from '../worktree-removal-execution-host-route'
@@ -30,11 +33,15 @@ import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup'
export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateManagedRemoteWorktree {
async removeManagedWorktree(
worktreeSelector: string,
force = false,
runHooks = false,
allowUnverifiedPtyStop = false,
hostId?: string
options: RemoveManagedWorktreeOptions = {}
): Promise<RemoveWorktreeResult & { warning?: string }> {
const {
force = false,
runHooks = false,
allowUnverifiedPtyStop = false,
allowFailedArchiveHook = false,
hostId
} = options
if (!this.store) {
throw new Error('runtime_unavailable')
}
@@ -45,7 +52,12 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM
worktreeId: removalTarget.id,
hostId: cleanupHostId
})
const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop)
const optionsKey = getRuntimeWorktreeRemovalOptionsKey({
force,
runHooks,
allowUnverifiedPtyStop,
allowFailedArchiveHook
})
const inFlightRemoval = this.removeManagedWorktreeInFlight.get(
cleanupScopeKey,
removalTarget.id,
@@ -190,6 +202,8 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM
}
if (route.kind === 'ssh') {
return removeRuntimeRegisteredRemoteWorktree({
runHooks,
allowFailedArchiveHook,
repo,
target: removalTarget,
registeredWorktree,
@@ -240,6 +254,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM
hasLocalOptions: hasLocalWorktreeGitOptions,
force,
runHooks,
allowFailedArchiveHook,
allowUnverifiedPtyStop,
deleteBranch,
acquireWatcherRemoval: this.acquireFileWatcherRemoval,
@@ -396,7 +396,7 @@ describe('OrcaRuntimeService', () => {
})
try {
await runtime.removeManagedWorktree('path:/remote/feature', true, false)
await runtime.removeManagedWorktree('path:/remote/feature', { force: true, runHooks: false })
} finally {
unregisterSshGitProvider('ssh-1')
}
@@ -472,7 +472,7 @@ describe('OrcaRuntimeService', () => {
runtime.registerPty('pty-local-same-id', `${TEST_REPO_ID}::/remote/feature`, null)
try {
await runtime.removeManagedWorktree('path:/remote/feature', true, false)
await runtime.removeManagedWorktree('path:/remote/feature', { force: true, runHooks: false })
} finally {
unregisterSshGitProvider('ssh-1')
}
@@ -519,9 +519,9 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(remoteStore as never)
try {
await expect(runtime.removeManagedWorktree('path:/remote/repo', true)).rejects.toThrow(
'Refusing to delete protected worktree path: /remote/repo'
)
await expect(
runtime.removeManagedWorktree('path:/remote/repo', { force: true })
).rejects.toThrow('Refusing to delete protected worktree path: /remote/repo')
} finally {
unregisterSshGitProvider('ssh-1')
}
@@ -298,7 +298,7 @@ describe('OrcaRuntimeService', () => {
.mockResolvedValue([])
try {
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
expect(result).toEqual({
preservedBranch: { branchName: 'feature/foo', head: 'abc' },
@@ -340,7 +340,9 @@ describe('OrcaRuntimeService', () => {
)
try {
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)).rejects.toThrow(
await expect(
runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
).rejects.toThrow(
`Failed to force delete worktree at ${TEST_WORKTREE_PATH}. error: failed to delete deep/file.txt: Filename too long`
)
expect(removePathSpy).not.toHaveBeenCalled()
@@ -387,7 +389,7 @@ describe('OrcaRuntimeService', () => {
})
try {
const result = await runtime.removeManagedWorktree(worktreeId, true)
const result = await runtime.removeManagedWorktree(worktreeId, { force: true })
expect(result).toEqual({
preservedBranch: { branchName: 'feature/foo', head: 'abc' }
@@ -425,9 +427,9 @@ describe('OrcaRuntimeService', () => {
vi.mocked(listWorktreesStrict).mockResolvedValue(registeredWorktrees)
vi.mocked(removeWorktree).mockResolvedValue({})
await expect(runtime.removeManagedWorktree(worktreeId, true, false)).rejects.toThrow(
'Worktree is locked by Git. Lock reason: active agent session'
)
await expect(
runtime.removeManagedWorktree(worktreeId, { force: true, runHooks: false })
).rejects.toThrow('Worktree is locked by Git. Lock reason: active agent session')
expect(removeWorktree).not.toHaveBeenCalled()
expect(removeWorktreeMeta).not.toHaveBeenCalled()
@@ -94,7 +94,12 @@ describe('OrcaRuntimeService', () => {
const runtime = createWorktreeRemovalRuntime(runtimeStore)
try {
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'ssh:ssh-1')
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: false,
runHooks: false,
allowUnverifiedPtyStop: false,
hostId: 'ssh:ssh-1'
})
expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, false)
expect(metaById[TEST_WORKTREE_ID]?.hostId).toBe('local')
const result = await runtime.forceDeletePreservedBranch(
@@ -181,8 +186,8 @@ describe('OrcaRuntimeService', () => {
return {}
})
const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)
const second = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)
const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
const second = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
await removeStarted.promise
await Promise.resolve()
@@ -238,14 +243,18 @@ describe('OrcaRuntimeService', () => {
registerSshGitProvider('host-b', provider as never)
try {
const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'local')
const remote = runtime.removeManagedWorktree(
TEST_WORKTREE_ID,
true,
false,
false,
'ssh:host-b'
)
const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: true,
runHooks: false,
allowUnverifiedPtyStop: false,
hostId: 'local'
})
const remote = runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: true,
runHooks: false,
allowUnverifiedPtyStop: false,
hostId: 'ssh:host-b'
})
await bothStarted.promise
expect(removeWorktree).toHaveBeenCalledTimes(1)
@@ -271,7 +280,7 @@ describe('OrcaRuntimeService', () => {
const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID)
await removeStarted.promise
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)).rejects.toThrow(
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })).rejects.toThrow(
'Worktree deletion already in progress'
)
@@ -292,7 +301,7 @@ describe('OrcaRuntimeService', () => {
try {
vi.mocked(listWorktrees).mockResolvedValue([])
await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({})
await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).resolves.toEqual({})
expect(removeWorktree).not.toHaveBeenCalled()
// The repo resolved to the local host, so the metadata purge names it —
@@ -458,7 +467,9 @@ describe('OrcaRuntimeService', () => {
})
try {
await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).resolves.toEqual({})
await expect(
runtime.removeManagedWorktree(`id:${worktreeId}`, { force: true })
).resolves.toEqual({})
} finally {
unregisterSshGitProvider(repo.connectionId)
unregisterSshFilesystemProvider(repo.connectionId)
@@ -513,7 +524,7 @@ describe('OrcaRuntimeService', () => {
try {
vi.mocked(listWorktrees).mockResolvedValue([])
await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({})
await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).resolves.toEqual({})
await expect(lstat(orphanPath)).rejects.toMatchObject({ code: 'ENOENT' })
expect(closeLocalWatcherForWorktreePathMock).toHaveBeenCalledWith(
@@ -586,7 +597,7 @@ describe('OrcaRuntimeService', () => {
expect(removeWorktree).not.toHaveBeenCalled()
expect(removeWorktreeMeta).not.toHaveBeenCalled()
await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({})
await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).resolves.toEqual({})
await expect(lstat(leftoverPath)).rejects.toMatchObject({ code: 'ENOENT' })
expect(assertWorktreeCleanForRemoval).not.toHaveBeenCalled()
@@ -642,7 +653,7 @@ describe('OrcaRuntimeService', () => {
try {
vi.mocked(listWorktrees).mockResolvedValue([])
await expect(runtime.removeManagedWorktree(worktreeId, true)).rejects.toThrow(
await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).rejects.toThrow(
`Refusing to delete unregistered worktree path: ${standalonePath}`
)
@@ -90,9 +90,9 @@ describe('OrcaRuntimeService', () => {
const runtime = createWorktreeRemovalRuntime(runtimeStore)
try {
await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).rejects.toThrow(
'SSH filesystem provider unavailable'
)
await expect(
runtime.removeManagedWorktree(`id:${worktreeId}`, { force: true })
).rejects.toThrow('SSH filesystem provider unavailable')
await expect(lstat(localPath)).resolves.toBeTruthy()
expect(removeWorktree).not.toHaveBeenCalled()
@@ -112,7 +112,7 @@ describe('OrcaRuntimeService', () => {
try {
vi.mocked(listWorktrees).mockResolvedValue([])
await expect(runtime.removeManagedWorktree(worktreeId, true)).rejects.toThrow(
await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).rejects.toThrow(
'Refusing to delete unregistered worktree path'
)
@@ -177,7 +177,9 @@ describe('OrcaRuntimeService', () => {
}
})
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, true)).rejects.toThrow(
await expect(
runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true, runHooks: true })
).rejects.toThrow(
`Refusing to delete worktree because it contains another registered worktree: ${TEST_WORKTREE_PATH}/child`
)
@@ -238,7 +240,9 @@ describe('OrcaRuntimeService', () => {
}
])
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, true)).rejects.toThrow(
await expect(
runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true, runHooks: true })
).rejects.toThrow(
`Failed to force delete worktree at ${TEST_WORKTREE_PATH}. Worktree is locked by Git.`
)
@@ -278,9 +282,9 @@ describe('OrcaRuntimeService', () => {
}
])
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, true)).rejects.toThrow(
'Worktree is locked by Git'
)
await expect(
runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true, runHooks: true })
).rejects.toThrow('Worktree is locked by Git')
expect(runHook).toHaveBeenCalled()
expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
@@ -377,7 +381,7 @@ describe('OrcaRuntimeService', () => {
vi.mocked(runHook).mockResolvedValue({ success: true, output: '' })
vi.mocked(removeWorktree).mockResolvedValue({})
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, true)
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: false, runHooks: true })
expect(runHook).toHaveBeenCalledWith(
'archive',
@@ -612,7 +612,12 @@ describe('OrcaRuntimeService', () => {
})
await expect(
runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'runtime:env-b')
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: false,
runHooks: false,
allowUnverifiedPtyStop: false,
hostId: 'runtime:env-b'
})
).rejects.toThrow('no longer belongs to runtime:env-b')
expect(localProvider.listProcesses).not.toHaveBeenCalled()
@@ -0,0 +1,242 @@
// Regression cover for #19334: a failed archive hook used to be logged and stepped over, so the
// checkout was deleted with nothing archived. The hook is a blocking precondition now.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
assertWorktreeCleanForRemoval,
deleteWorktreeHistoryDirMock,
getEffectiveHooks,
invalidateAuthorizedRootsCacheMock,
listWorktreesStrict,
removeWorktree,
removeWorktreeLinkedPathsMock,
runHook
} from '../orca-runtime-test-mocks.spec'
import {
TEST_REPO_PATH,
TEST_WORKTREE_ID,
TEST_WORKTREE_PATH,
createStaleRuntimeWorktreeStore,
deferred
} from '../orca-runtime-test-fixtures.spec'
import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec'
import {
ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
asArchiveHookRefusal
} from '../../../shared/worktree/archive-hook-removal-gate'
function withArchiveHook(): void {
vi.mocked(getEffectiveHooks).mockReturnValue({
scripts: { archive: 'pnpm worktree:archive' }
})
}
function expectNothingMutated(removeWorktreeMeta: ReturnType<typeof vi.fn>): void {
// The checkout, its Git registration, its agents and Orca's ownership evidence all survive.
expect(removeWorktree).not.toHaveBeenCalled()
expect(removeWorktreeMeta).not.toHaveBeenCalled()
expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
expect(invalidateAuthorizedRootsCacheMock).not.toHaveBeenCalled()
// The gate runs before the registration re-read, so even the preflights never start. The one
// listing is the orchestrator's own lookup ahead of the hook; the post-hook refresh never runs.
expect(listWorktreesStrict).toHaveBeenCalledTimes(1)
expect(assertWorktreeCleanForRemoval).not.toHaveBeenCalled()
}
describe('archive hook removal gate', () => {
// These specs are imported into one aggregate test file, so the module-level mocks arrive with
// calls from earlier specs. Clear counts here and restore the shared defaults afterwards.
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.mocked(getEffectiveHooks).mockReturnValue(null)
vi.mocked(runHook).mockResolvedValue({ success: true, output: '' })
})
it('refuses removal and mutates nothing when the archive hook exits 23', async () => {
const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
const runtime = createWorktreeRemovalRuntime(runtimeStore)
withArchiveHook()
vi.mocked(runHook).mockResolvedValue({
success: false,
output: 'backup target unreachable',
exitCode: 23
})
const failure = await runtime
.removeManagedWorktree(TEST_WORKTREE_ID, { force: false, runHooks: true })
.catch((error: unknown) => error)
const refusal = asArchiveHookRefusal(failure)
expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
expect(refusal.data).toEqual({
worktreePath: TEST_WORKTREE_PATH,
outcome: 'exited',
exitCode: 23,
output: 'backup target unreachable'
})
expectNothingMutated(removeWorktreeMeta)
})
it('refuses removal when the hook never reported an exit, without claiming it passed', async () => {
const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
const runtime = createWorktreeRemovalRuntime(runtimeStore)
withArchiveHook()
// A timeout or a lost execution host yields no exit code: `unverifiable`, never a pass.
vi.mocked(runHook).mockResolvedValue({
success: false,
output: 'Hook timed out after 120000ms.'
})
const failure = await runtime
.removeManagedWorktree(TEST_WORKTREE_ID, { force: false, runHooks: true })
.catch((error: unknown) => error)
const refusal = asArchiveHookRefusal(failure)
expect(refusal.data).toEqual({
worktreePath: TEST_WORKTREE_PATH,
outcome: 'unverifiable',
output: 'Hook timed out after 120000ms.'
})
expectNothingMutated(removeWorktreeMeta)
})
it('does not let --force waive a failed archive hook', async () => {
const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
const runtime = createWorktreeRemovalRuntime(runtimeStore)
withArchiveHook()
vi.mocked(runHook).mockResolvedValue({
success: false,
output: 'boom',
exitCode: 23
})
await expect(
// force + the PTY-stop waiver, i.e. everything the desktop Force Delete sets.
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: true,
runHooks: true,
allowUnverifiedPtyStop: true
})
).rejects.toMatchObject({ code: ARCHIVE_HOOK_FAILED_REMOVAL_CODE })
expectNothingMutated(removeWorktreeMeta)
})
it('removes and records the waiver when the failure is explicitly overridden', async () => {
const runtime = createWorktreeRemovalRuntime()
withArchiveHook()
vi.mocked(runHook).mockResolvedValue({
success: false,
output: 'boom',
exitCode: 23
})
vi.mocked(removeWorktree).mockResolvedValue({})
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: false,
runHooks: true,
allowUnverifiedPtyStop: false,
allowFailedArchiveHook: true
})
expect(result.archiveHookOverride).toEqual({
worktreePath: TEST_WORKTREE_PATH,
outcome: 'exited',
exitCode: 23,
output: 'boom',
overridden: true
})
expect(removeWorktree).toHaveBeenCalledWith(
TEST_REPO_PATH,
TEST_WORKTREE_PATH,
false,
expect.objectContaining({
knownRemovedWorktree: expect.objectContaining({
path: TEST_WORKTREE_PATH
})
})
)
})
it('removes without an override record when the hook succeeds', async () => {
const runtime = createWorktreeRemovalRuntime()
withArchiveHook()
vi.mocked(runHook).mockResolvedValue({
success: true,
output: '',
exitCode: 0
})
vi.mocked(removeWorktree).mockResolvedValue({})
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: false,
runHooks: true
})
expect(result.archiveHookOverride).toBeUndefined()
expect(removeWorktree).toHaveBeenCalled()
})
it('removes when the hook is configured but not requested', async () => {
const runtime = createWorktreeRemovalRuntime()
withArchiveHook()
vi.mocked(removeWorktree).mockResolvedValue({})
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID)
expect(runHook).not.toHaveBeenCalled()
expect(result.warning).toContain('archive hook skipped')
expect(removeWorktree).toHaveBeenCalled()
})
it('removes when no archive hook is configured', async () => {
const runtime = createWorktreeRemovalRuntime()
vi.mocked(getEffectiveHooks).mockReturnValue(null)
vi.mocked(removeWorktree).mockResolvedValue({})
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: false,
runHooks: true
})
expect(runHook).not.toHaveBeenCalled()
expect(result.warning).toBeUndefined()
expect(removeWorktree).toHaveBeenCalled()
})
it('does not coalesce an override retry onto the refusal already in flight', async () => {
const runtime = createWorktreeRemovalRuntime()
withArchiveHook()
const hookRun = deferred<{
success: boolean
output: string
exitCode?: number
}>()
vi.mocked(runHook).mockReturnValue(hookRun.promise)
vi.mocked(removeWorktree).mockResolvedValue({})
const refused = runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: false,
runHooks: true
})
await vi.waitFor(() => expect(runHook).toHaveBeenCalled())
// The waiver is part of the in-flight options key, so a concurrent waived retry is refused
// outright rather than handed the in-flight attempt that is about to reject on the hook.
await expect(
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: false,
runHooks: true,
allowUnverifiedPtyStop: false,
allowFailedArchiveHook: true
})
).rejects.toThrow('Worktree deletion already in progress')
hookRun.resolve({ success: false, output: 'boom', exitCode: 23 })
await expect(refused).rejects.toMatchObject({
code: ARCHIVE_HOOK_FAILED_REMOVAL_CODE
})
})
})
@@ -102,7 +102,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() })
try {
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a')
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: true,
runHooks: false,
allowUnverifiedPtyStop: false,
hostId: 'ssh:target-a'
})
expect(provider.listWorktrees).toHaveBeenCalledWith(REMOTE_REPO_PATH)
expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, true)
@@ -127,7 +132,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
try {
await expect(
runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a')
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: true,
runHooks: false,
allowUnverifiedPtyStop: false,
hostId: 'ssh:target-a'
})
).resolves.toEqual({})
expect(provider.listWorktrees).toHaveBeenCalledWith(REMOTE_REPO_PATH)
@@ -152,7 +162,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() })
try {
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-b')
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: true,
runHooks: false,
allowUnverifiedPtyStop: false,
hostId: 'ssh:target-b'
})
expect(providerB.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, true)
expect(providerA.listWorktrees).not.toHaveBeenCalled()
@@ -168,7 +183,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
const runtime = createWorktreeRemovalRuntime(runtimeStore)
await expect(
runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a')
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: true,
runHooks: false,
allowUnverifiedPtyStop: false,
hostId: 'ssh:target-a'
})
).rejects.toThrow('Remote connection dropped')
expect(listWorktreesStrict).not.toHaveBeenCalled()
@@ -181,7 +201,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
const runtime = createWorktreeRemovalRuntime(runtimeStore)
await expect(
runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'runtime:env-1')
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: true,
runHooks: false,
allowUnverifiedPtyStop: false,
hostId: 'runtime:env-1'
})
).rejects.toThrow('not dispatched by this process')
expect(listWorktreesStrict).not.toHaveBeenCalled()
@@ -201,7 +226,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
try {
await expect(
runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'runtime:env-1')
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
force: true,
runHooks: false,
allowUnverifiedPtyStop: false,
hostId: 'runtime:env-1'
})
).rejects.toThrow('not dispatched by this process')
// Selector resolution still lists through the raw field before removal begins — a read on
+1
View File
@@ -110,6 +110,7 @@ await import('./orca-runtime-tests/worktree-removal-and-reconciliation.spec')
await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec')
await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec')
await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec')
await import('./orca-runtime-tests/worktree-removal-archive-hook-gate.spec')
await import('./orca-runtime-tests/worktree-removal-execution-host.spec')
await import('./orca-runtime-tests/targeting-and-resilience.spec')
await import('./orca-runtime-tests/worktree-scan-cache-ttl.spec')
+4
View File
@@ -22,6 +22,7 @@ import {
} from '../../../shared/skill-install-failure'
import { GIT_DIFF_TOO_LARGE_CODE } from '../../../shared/git-diff-transport-budget'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../shared/automation-owner-conflict'
import { ARCHIVE_HOOK_FAILED_REMOVAL_CODE } from '../../../shared/worktree/archive-hook-removal-gate'
import { NESTED_WORKER_DEPTH_EXCEEDED_CODE } from '../../../shared/nested-worker-depth'
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
@@ -126,6 +127,9 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
'stale_delivery',
'waiter_exists',
'invalid_argument',
// Why (#19334): "your archive hook failed, nothing was deleted" is a distinct decision — retry,
// waive, or skip the hook. Flattened to runtime_error a caller can only pattern-match the text.
ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
NESTED_WORKER_DEPTH_EXCEEDED_CODE,
GIT_DIFF_TOO_LARGE_CODE,
ARTIFACT_SHARING_DISABLED_CODE,
@@ -20,6 +20,15 @@ function makeRequest(params: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method: 'worktree.rm', params }
}
/** The removal options every case forwards; only the resolved host differs. */
const forwarded = (hostId?: string): Record<string, unknown> => ({
force: true,
runHooks: false,
allowUnverifiedPtyStop: false,
allowFailedArchiveHook: false,
...(hostId ? { hostId } : {})
})
describe('worktree.rm host qualification', () => {
it('routes an explicitly qualified removal to that host', async () => {
const runtime = makeRuntime()
@@ -29,13 +38,7 @@ describe('worktree.rm host qualification', () => {
makeRequest({ worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false })
)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
'id:wt-1',
true,
false,
false,
'local'
)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', forwarded('local'))
expect(response).toMatchObject({ ok: true, result: { removed: true } })
})
@@ -54,10 +57,7 @@ describe('worktree.rm host qualification', () => {
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
`id:${WORKTREE_ID}`,
true,
false,
false,
'local'
forwarded('local')
)
expect(response).toMatchObject({ ok: true, result: { removed: true } })
})
@@ -77,10 +77,7 @@ describe('worktree.rm host qualification', () => {
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
`id:${WORKTREE_ID}`,
true,
false,
false,
'runtime:env-1'
forwarded('runtime:env-1')
)
})
@@ -119,10 +116,7 @@ describe('worktree.rm host qualification', () => {
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
`id:${WORKTREE_ID}`,
true,
false,
false,
'ssh:target-a'
forwarded('ssh:target-a')
)
})
@@ -151,13 +145,7 @@ describe('worktree.rm host qualification', () => {
)
expect(runtime.showManagedWorktree).toHaveBeenCalledWith('id:wt-1')
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
'id:wt-1',
true,
false,
false,
'local'
)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', forwarded('local'))
expect(response).toMatchObject({ ok: true, result: { removed: true } })
})
@@ -205,13 +193,7 @@ describe('worktree.rm host qualification', () => {
expect(response).toMatchObject({ ok: true, result: { removed: true } })
// Unqualified on purpose: removeManagedWorktree owns the stale-row path and
// still refuses on its own if the id turns out to have two owners.
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
'id:wt-gone',
true,
false,
false,
undefined
)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-gone', forwarded())
})
it('propagates a non-missing lookup failure instead of deleting unqualified', async () => {
@@ -14,74 +14,75 @@ function makeRuntime(): OrcaRuntimeService {
} as unknown as OrcaRuntimeService
}
// Why (#11960): waiving the proof that every PTY stopped must ride its own field.
// The desktop sets `force` for an ordinary confirmed delete, so keying the waiver
// off `force` would silently disable the gate on the primary delete path.
describe('worktree.rm PTY-stop waiver', () => {
it('forwards an explicit waiver to the runtime', async () => {
/** The dispatcher validates against the Zod schema, so the test spells the wire shape. */
type RmParams = {
hostId?: string
force?: boolean
runHooks?: boolean
allowUnverifiedPtyStop?: boolean
allowFailedArchiveHook?: boolean
}
async function dispatchRm(runtime: OrcaRuntimeService, params: RmParams): Promise<void> {
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const request: RpcRequest = {
id: 'req-1',
authToken: 'tok',
method: 'worktree.rm',
params: { worktree: 'id:wt-1', ...params }
}
await dispatcher.dispatch(request)
}
/** Every waiver off unless a case turns it on — the defaults are the assertion. */
const forwarded = (overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> => ({
force: false,
runHooks: false,
allowUnverifiedPtyStop: false,
allowFailedArchiveHook: false,
hostId: 'local',
...overrides
})
// Why (#11960 and #19334): each waiver rides its own field. The desktop sets `force` for an
// ordinary confirmed delete, so keying either waiver off `force` would silently disable that gate
// on the primary delete path. These cases exist to keep `force` from acquiring a second meaning.
describe('worktree.rm waivers travel on their own fields', () => {
it.each([
[
'an explicit PTY-stop waiver reaches the runtime',
{ hostId: 'local', force: true, allowUnverifiedPtyStop: true, runHooks: false },
forwarded({ force: true, allowUnverifiedPtyStop: true })
],
[
'force alone does NOT waive the PTY-stop proof',
{ hostId: 'local', force: true, runHooks: false },
forwarded({ force: true })
],
[
'an explicit archive-hook waiver reaches the runtime',
{ hostId: 'local', runHooks: true, allowFailedArchiveHook: true },
forwarded({ runHooks: true, allowFailedArchiveHook: true })
],
[
'force plus a PTY waiver does NOT waive a failed archive hook',
{ hostId: 'local', force: true, allowUnverifiedPtyStop: true, runHooks: true },
forwarded({ force: true, runHooks: true, allowUnverifiedPtyStop: true })
]
])('%s', async (_name, params, expected) => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
await dispatcher.dispatch({
id: 'req-1',
authToken: 'tok',
method: 'worktree.rm',
params: {
worktree: 'id:wt-1',
hostId: 'local',
force: true,
allowUnverifiedPtyStop: true,
runHooks: false
}
} satisfies RpcRequest)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
'id:wt-1',
true,
false,
true,
'local'
)
})
it('does not infer a waiver from force alone', async () => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
await dispatcher.dispatch({
id: 'req-1',
authToken: 'tok',
method: 'worktree.rm',
params: { worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false }
} satisfies RpcRequest)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
'id:wt-1',
true,
false,
false,
'local'
)
await dispatchRm(runtime, params)
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', expected)
})
it('resolves the host before forwarding an unqualified removal', async () => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
await dispatcher.dispatch({
id: 'req-1',
authToken: 'tok',
method: 'worktree.rm',
params: { worktree: 'id:wt-1', force: true, runHooks: false }
} satisfies RpcRequest)
await dispatchRm(runtime, { force: true, runHooks: false })
expect(runtime.showManagedWorktree).toHaveBeenCalledWith('id:wt-1')
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
'id:wt-1',
true,
false,
false,
'ssh:builder'
forwarded({ force: true, hostId: 'ssh:builder' })
)
})
})
+7 -7
View File
@@ -235,13 +235,13 @@ export const WORKTREE_METHODS = [
}
}
}
const removalArgs = [
params.worktree,
params.force === true,
params.runHooks === true,
params.allowUnverifiedPtyStop === true
] as const
const result = await runtime.removeManagedWorktree(...removalArgs, resolvedHostId)
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 }
}
}),
@@ -1,5 +1,7 @@
import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types'
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import type { ArchiveHookOverride } from '../../shared/worktree/archive-hook-removal-gate'
import { gateWorktreeRemovalOnArchiveHook } from '../worktree-archive-hook-gate'
import type { Repo } from '../../shared/repo-types'
import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal'
import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
@@ -40,6 +42,8 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
hasLocalOptions: boolean
force: boolean
runHooks: boolean
/** Explicit waiver for a FAILED archive hook. Never implied by `force` — see #19334. */
allowFailedArchiveHook: boolean
allowUnverifiedPtyStop: boolean
deleteBranch: boolean
acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise<void> }>
@@ -60,6 +64,9 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
const canonicalPath = registeredWorktree.path
const hooks = getEffectiveHooks(repo)
let warning: string | undefined
// Precondition, not an advisory: this runs before the registration refresh, the preflights, the
// PTY stop and `removeWorktree`, so a throw here leaves every one of them untouched (#19334).
let archiveHookOverride: ArchiveHookOverride | undefined
if (hooks?.scripts.archive && args.runHooks) {
const result = await runHook(
'archive',
@@ -68,9 +75,11 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
undefined,
args.hasLocalOptions ? localOptions : undefined
)
if (!result.success) {
console.error(`[hooks] archive hook failed for ${canonicalPath}:`, result.output)
}
archiveHookOverride = gateWorktreeRemovalOnArchiveHook({
worktreePath: canonicalPath,
result,
allowFailure: args.allowFailedArchiveHook
})
} else if (hooks?.scripts.archive) {
warning = `orca.yaml archive hook skipped for ${canonicalPath}; pass --run-hooks to run it.`
console.warn(`[hooks] ${warning}`)
@@ -151,7 +160,10 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
await cleanupPushTarget(args)
args.finishRemoval(undefined, false, refreshed.head)
completed = true
return warning ? { warning } : {}
return {
...(archiveHookOverride ? { archiveHookOverride } : {}),
...(warning ? { warning } : {})
}
} else {
throw new Error(formatWorktreeRemovalError(error, canonicalPath, args.force))
}
@@ -162,7 +174,11 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
}
await cleanupPushTarget(args)
args.finishRemoval(removalResult, true, refreshed.head)
return { ...removalResult, ...(warning ? { warning } : {}) }
return {
...removalResult,
...(archiveHookOverride ? { archiveHookOverride } : {}),
...(warning ? { warning } : {})
}
}
async function cleanupOrphanedDirectory(
@@ -5,6 +5,7 @@ import type { SshGitProvider } from '../providers/ssh-git-provider'
import { cleanupUnusedWorktreePushTargetRemoteSsh } from '../ipc/worktree-remote'
import type { RuntimeStore } from './runtime-store-contract'
import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection'
import { gateRemovalWhereArchiveHookCannotRun } from '../worktree-archive-hook-gate'
export async function removeRuntimeRegisteredRemoteWorktree(args: {
repo: Repo
@@ -15,6 +16,10 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: {
provider: SshGitProvider
/** From the resolved removal route; `repo.connectionId!` answered null for an `ssh:`-only row. */
connectionId: string
/** #19334: this path runs no archive hook, so the gate below decides what that means. */
runHooks: boolean
/** Explicit waiver for that refusal; without it the block has no exit on this path. */
allowFailedArchiveHook: boolean
force: boolean
allowUnverifiedPtyStop: boolean
deleteBranch: boolean
@@ -29,8 +34,17 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: {
fallbackHead: string | undefined
) => RemoveWorktreeResult
finishRemoval: (result: RemoveWorktreeResult) => void
}): Promise<RemoveWorktreeResult> {
}): Promise<RemoveWorktreeResult & { warning?: string }> {
const { repo, target, registeredWorktree, provider, connectionId } = args
// Precondition, before anything is stopped or deleted: no archive hook runs here, so a removal
// that asked for one refuses rather than deleting with the archive step silently skipped.
const hookGate = await gateRemovalWhereArchiveHookCannotRun({
repo,
connectionId,
worktreePath: registeredWorktree.path,
runHooks: args.runHooks,
allowFailedArchiveHook: args.allowFailedArchiveHook
})
const removeOptions = !args.deleteBranch ? { deleteBranch: args.deleteBranch } : {}
const gate = await args.acquireWatcherRemoval(registeredWorktree.path, connectionId)
let rawResult: RemoveWorktreeResult | undefined
@@ -54,5 +68,9 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: {
)
await args.deleteHistory()
args.finishRemoval(result)
return result
return {
...result,
...(hookGate.override ? { archiveHookOverride: hookGate.override } : {}),
...(hookGate.warning ? { warning: hookGate.warning } : {})
}
}
@@ -1,5 +1,39 @@
import { describe, expect, it } from 'vitest'
import { runtimeRepoMatchesExecutionHost } from './runtime-worktree-selection'
import {
getRuntimeWorktreeRemovalOptionsKey,
runtimeRepoMatchesExecutionHost
} from './runtime-worktree-selection'
describe('getRuntimeWorktreeRemovalOptionsKey', () => {
it('separates a waived archive-hook retry from the attempt about to refuse on it (#19334)', () => {
const strict = getRuntimeWorktreeRemovalOptionsKey({ runHooks: true })
expect(
getRuntimeWorktreeRemovalOptionsKey({ runHooks: true, allowFailedArchiveHook: true })
).not.toBe(strict)
})
it('keeps every waiver on its own axis, so none of them coalesce', () => {
const keys = [
{},
{ force: true },
{ runHooks: true },
{ allowUnverifiedPtyStop: true },
{ allowFailedArchiveHook: true }
].map(getRuntimeWorktreeRemovalOptionsKey)
expect(new Set(keys).size).toBe(keys.length)
})
it('treats an omitted option as its off value', () => {
expect(getRuntimeWorktreeRemovalOptionsKey({})).toBe(
getRuntimeWorktreeRemovalOptionsKey({
force: false,
runHooks: false,
allowUnverifiedPtyStop: false,
allowFailedArchiveHook: false
})
)
})
})
describe('runtimeRepoMatchesExecutionHost', () => {
it('matches an unstamped SSH repo against its own host (#11163)', () => {
+25 -5
View File
@@ -26,15 +26,35 @@ export function gitStatusErrorMeansNotRepository(error: unknown): boolean {
return /not a git repository/i.test(`${message}\n${stderr}`)
}
/**
* Options for `removeManagedWorktree`. Named rather than positional on purpose: three of the
* four are interchangeable booleans that each waive a different safety check on a destructive
* delete, so a transposition would silently delete a checkout the caller meant to protect.
*/
export type RemoveManagedWorktreeOptions = {
force?: boolean
runHooks?: boolean
/** Waives proof that every PTY stopped (#11960). Set by explicit Force Delete only. */
allowUnverifiedPtyStop?: boolean
/** Waives a FAILED archive hook (#19334). Never implied by `force`, never by `runHooks`. */
allowFailedArchiveHook?: boolean
hostId?: string
}
export function getRuntimeWorktreeRemovalOptionsKey(
force: boolean,
runHooks: boolean,
allowUnverifiedPtyStop: boolean
options: Pick<
RemoveManagedWorktreeOptions,
'force' | 'runHooks' | 'allowUnverifiedPtyStop' | 'allowFailedArchiveHook'
>
): string {
// Why: a forced retry must not coalesce onto the in-flight attempt that just
// failed the PTY gate — it would inherit that failure instead of retrying.
const ptyKey = allowUnverifiedPtyStop ? 'allow-unverified-pty' : 'require-pty-stop'
return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}:${ptyKey}`
const ptyKey = options.allowUnverifiedPtyStop ? 'allow-unverified-pty' : 'require-pty-stop'
// Same reason for the archive waiver: a retry that waives the failed hook must not coalesce
// onto the in-flight attempt that is about to refuse on it.
const archiveKey = options.allowFailedArchiveHook ? 'allow-failed-archive' : 'require-archive'
const hooksKey = options.runHooks ? 'run-hooks' : 'skip-hooks'
return `${options.force ? 'force' : 'normal'}:${hooksKey}:${ptyKey}:${archiveKey}`
}
// Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest'
import type { Repo } from '../shared/repo-types'
import { gateRemovalWhereArchiveHookCannotRun } from './worktree-archive-hook-gate'
import {
ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
asArchiveHookRefusal
} from '../shared/worktree/archive-hook-removal-gate'
// Mocked at the SSH-aware reader, because that is the whole point: on an SSH worktree the hook
// lives on the execution host, not on the runtime's local disk.
const { getArchiveHooksForRemovalMock } = vi.hoisted(() => ({
getArchiveHooksForRemovalMock: vi.fn()
}))
vi.mock('./ipc/worktrees/removal/worktree-archive-hook', () => ({
getArchiveHooksForRemoval: getArchiveHooksForRemovalMock
}))
const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 }
const withArchiveHook = (present: boolean): void => {
getArchiveHooksForRemovalMock.mockResolvedValue(
present ? { scripts: { archive: 'archive.sh' } } : null
)
}
const gate = (over: Partial<Parameters<typeof gateRemovalWhereArchiveHookCannotRun>[0]> = {}) =>
gateRemovalWhereArchiveHookCannotRun({
repo: REPO,
connectionId: undefined,
worktreePath: '/w/f',
runHooks: true,
allowFailedArchiveHook: false,
...over
})
// Why (#19334 / S1): the runtime's SSH path runs no archive hook. Silently deleting there would
// reproduce the reported bug in the one place `worktree.archive-failure-blocking.v1` promises it
// cannot happen, so the capability would be advertising a guarantee it does not keep.
describe('gateRemovalWhereArchiveHookCannotRun', () => {
it('lets a repo with no archive hook through untouched', async () => {
withArchiveHook(false)
await expect(gate()).resolves.toEqual({})
})
it('warns rather than refuses when hooks were not requested', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
withArchiveHook(true)
await expect(gate({ runHooks: false })).resolves.toMatchObject({
warning: expect.stringContaining('pass --run-hooks to run it')
})
})
// Why (#19334): reading locally would miss the committed hook on an SSH host entirely.
it('asks the execution host whether a hook exists, not the local disk', async () => {
withArchiveHook(false)
await gate({ connectionId: 'ssh-target' })
expect(getArchiveHooksForRemovalMock).toHaveBeenCalledWith(REPO, 'ssh-target')
})
it('refuses a hooks-requested removal it cannot honour, as unverifiable', async () => {
withArchiveHook(true)
const refusal = asArchiveHookRefusal(await gate().catch((error: unknown) => error))
expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
// Never `exited`: nothing ran, so nothing reported an exit to read.
expect(refusal.data).toMatchObject({ worktreePath: '/w/f', outcome: 'unverifiable' })
expect(refusal.data.exitCode).toBeUndefined()
})
// Why this matters: without it the refusal is a dead loop. The desktop's "Delete Anyway" and the
// CLI's --allow-failed-archive-hook both land here, and a block with no reachable exit on the
// surface where it happens is the failure mode this PR fixed on the desktop path.
it('deletes anyway when the refusal is explicitly waived, and records it', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
withArchiveHook(true)
const result = await gate({ allowFailedArchiveHook: true })
expect(result.warning).toBeUndefined()
expect(result.override).toMatchObject({
worktreePath: '/w/f',
outcome: 'unverifiable',
overridden: true
})
})
})
+86
View File
@@ -0,0 +1,86 @@
import type { Repo } from '../shared/repo-types'
import { getArchiveHooksForRemoval } from './ipc/worktrees/removal/worktree-archive-hook'
import {
WorktreeArchiveHookFailedError,
formatArchiveHookOverride,
type ArchiveHookFailure,
classifyArchiveHookFailure,
formatArchiveHookFailure,
type ArchiveHookOverride,
type ArchiveHookRunResult
} from '../shared/worktree/archive-hook-removal-gate'
/**
* The archive-hook precondition for a destructive worktree removal (#19334). Call it while the
* checkout, its registration, its agents and its ownership evidence are all still intact: on a
* failure it throws, and no caller may stop a PTY, deregister, or delete before it has returned.
*
* Returns the override record when the failure was explicitly waived, `undefined` on success.
*/
export function gateWorktreeRemovalOnArchiveHook(args: {
worktreePath: string
result: ArchiveHookRunResult
allowFailure: boolean
}): ArchiveHookOverride | undefined {
if (args.result.success) {
return undefined
}
const failure = classifyArchiveHookFailure(args.worktreePath, args.result)
if (!args.allowFailure) {
console.error(`[hooks] ${formatArchiveHookFailure(failure)}`)
throw new WorktreeArchiveHookFailedError(failure)
}
console.warn(
`[hooks] archive hook failure overridden for ${args.worktreePath}; deleting anyway:`,
args.result.output
)
return { ...failure, overridden: true }
}
/**
* The runtime's SSH removal path cannot run an archive hook at all (see #18563, which adds it).
* Until it can, a removal that asked for hooks has to refuse rather than delete: deleting would
* repeat exactly the bug this gate exists to stop, and reporting success would make
* `worktree.archive-failure-blocking.v1` a lie in the one case the reporter asked it to cover.
*
* Modelled as `unverifiable` because that is what it is — the hook's outcome was never observed —
* so it reuses the same typed error, the same `--allow-failed-archive-hook` waiver, and the same
* desktop "Delete Anyway" affordance as any other unobserved hook. Waiving it records the same
* `archiveHookOverride` the other paths return, so a caller is told what it accepted.
*
* Returns the skipped-hook warning when hooks were not requested, matching the local path.
*
* Hooks are read through `getArchiveHooksForRemoval` rather than `getEffectiveHooks`: on an
* SSH-hosted worktree `repo.path` names a path on the EXECUTION host, so a local read would miss
* the committed `orca.yaml` this gate exists for, and could refuse on a coincidental local one.
*/
export async function gateRemovalWhereArchiveHookCannotRun(args: {
repo: Repo
/** The removal route's owner; `repo.connectionId` is null for an `ssh:`-only row. */
connectionId: string | undefined
worktreePath: string
runHooks: boolean
/** Explicit waiver. Without it the refusal below has no exit on this path. */
allowFailedArchiveHook: boolean
}): Promise<{ warning?: string; override?: ArchiveHookOverride }> {
const hooks = await getArchiveHooksForRemoval(args.repo, args.connectionId)
if (!hooks?.scripts.archive) {
return {}
}
if (!args.runHooks) {
const warning = `orca.yaml archive hook skipped for ${args.worktreePath}; pass --run-hooks to run it.`
console.warn(`[hooks] ${warning}`)
return { warning }
}
const failure: ArchiveHookFailure = {
worktreePath: args.worktreePath,
outcome: 'unverifiable',
output:
'This host cannot run an archive hook for an SSH-hosted worktree, so the hook never ran. Remove it from the desktop app, which does run it, or delete anyway to accept that nothing was archived.'
}
if (!args.allowFailedArchiveHook) {
throw new WorktreeArchiveHookFailedError(failure)
}
console.warn(`[hooks] ${formatArchiveHookOverride({ ...failure, overridden: true })}`)
return { override: { ...failure, overridden: true } }
}
+3
View File
@@ -98,6 +98,9 @@ export type WorktreeApi = {
// may waive the proof that every PTY stopped.
allowUnverifiedPtyStop?: boolean
skipArchive?: boolean
// Why (#19334): distinct from `skipArchive` (never runs the hook) and never implied by
// `force` — this waives a hook that ran and FAILED.
allowFailedArchiveHook?: boolean
snapshotPruneBatchId?: string
}) => Promise<RemoveWorktreeResult>
// Forget a workspace from Orca only (no remote Git/FS work) — for workspaces pinned to a removed/disconnected SSH host.
@@ -102,6 +102,13 @@ function showDeleteFailureToast(): void {
),
canForceDelete: true,
forceDeleteReason: 'dirty',
onDeleteAnyway: () =>
toast.error(
translate(
'auto.components.settings.DevToolsPane.deleteAnywayClicked',
'Delete Anyway clicked'
)
),
onViewChanges: () =>
toast.message(
translate(
@@ -51,6 +51,7 @@ describe('showDeleteWorktreeFailureToast', () => {
it('uses a persistent in-body action footer when force delete is available', () => {
const onViewChanges = vi.fn()
const onForceDelete = vi.fn()
const onDeleteAnyway = vi.fn()
showDeleteWorktreeFailureToast({
error: 'branch has changes',
@@ -58,6 +59,7 @@ describe('showDeleteWorktreeFailureToast', () => {
forceDeleteReason: 'dirty',
onViewChanges,
onForceDelete,
onDeleteAnyway,
worktreeId: 'wt-1',
worktreeName: 'feature/foo'
})
@@ -97,6 +99,7 @@ describe('showDeleteWorktreeFailureToast', () => {
forceDeleteReason: null,
onViewChanges,
onForceDelete: vi.fn(),
onDeleteAnyway: vi.fn(),
worktreeId: 'wt-2',
worktreeName: 'feature/bar'
})
@@ -119,6 +122,53 @@ describe('showDeleteWorktreeFailureToast', () => {
expect(onViewChanges).toHaveBeenCalled()
})
// #19334: the archive-hook refusal is the one failure a user clears by waiving rather than by
// fixing state, and the desktop is where most people meet it.
it('offers Delete Anyway when the archive hook refused the removal', () => {
const onDeleteAnyway = vi.fn()
showDeleteWorktreeFailureToast({
error: 'Archive hook failed for worktree: /w/feature — exited 23.',
canForceDelete: false,
forceDeleteReason: null,
canWaiveArchiveHook: true,
onViewChanges: vi.fn(),
onForceDelete: vi.fn(),
onDeleteAnyway,
worktreeId: 'wt-archive',
worktreeName: 'feature/archive'
})
expect(toast.error).toHaveBeenCalledWith(
'Failed to delete workspace feature/archive',
// The user has to read the reason before choosing, so this toast must not expire.
expect.objectContaining({ duration: Infinity })
)
const body = renderToastBody('error')
expect(body.textContent).toContain('Delete Anyway')
expect(body.textContent).not.toContain('Force Delete')
clickButton(body, 'Delete Anyway')
expect(toast.dismiss).toHaveBeenCalledWith('delete-worktree-failure:wt-archive')
expect(onDeleteAnyway).toHaveBeenCalled()
})
it('does not offer Delete Anyway for an ordinary failure', () => {
showDeleteWorktreeFailureToast({
error: 'permission denied',
canForceDelete: false,
forceDeleteReason: null,
onViewChanges: vi.fn(),
onForceDelete: vi.fn(),
onDeleteAnyway: vi.fn(),
worktreeId: 'wt-plain',
worktreeName: 'feature/plain'
})
expect(renderToastBody('error').textContent).not.toContain('Delete Anyway')
})
it('offers neither force delete nor View for a locked workspace', () => {
const onViewChanges = vi.fn()
@@ -128,6 +178,7 @@ describe('showDeleteWorktreeFailureToast', () => {
forceDeleteReason: null,
onViewChanges,
onForceDelete: vi.fn(),
onDeleteAnyway: vi.fn(),
worktreeId: 'wt-locked',
worktreeName: 'feature/locked'
})
@@ -151,6 +202,7 @@ describe('showDeleteWorktreeFailureToast', () => {
hasKnownChanges: true,
onViewChanges: vi.fn(),
onForceDelete: vi.fn(),
onDeleteAnyway: vi.fn(),
worktreeId: 'wt-locked-dirty',
worktreeName: 'feature/locked-dirty'
})
@@ -13,8 +13,11 @@ type DeleteWorktreeFailureToastOptions = {
forceDeleteReason: WorktreeForceDeleteReason | null
lockReason?: string | null
hasKnownChanges?: boolean
/** The archive hook refused this removal, so the user may waive it (#19334). */
canWaiveArchiveHook?: boolean
onViewChanges: () => void
onForceDelete: () => void
onDeleteAnyway: () => void
worktreeId: string
worktreeName: string
}
@@ -26,16 +29,20 @@ function deleteWorktreeFailureToastId(worktreeId: string): string {
function DeleteWorktreeFailureToastBody({
description,
canForceDelete,
canWaiveArchiveHook,
showViewChanges,
onViewChanges,
onForceDelete,
onDeleteAnyway,
toastId
}: {
description?: string
canForceDelete: boolean
canWaiveArchiveHook: boolean
showViewChanges: boolean
onViewChanges: () => void
onForceDelete: () => void
onDeleteAnyway: () => void
toastId: string
}): React.JSX.Element {
const viewChanges = (): void => {
@@ -46,6 +53,10 @@ function DeleteWorktreeFailureToastBody({
toast.dismiss(toastId)
onForceDelete()
}
const deleteAnyway = (): void => {
toast.dismiss(toastId)
onDeleteAnyway()
}
return (
<div className="flex w-full flex-col gap-3">
@@ -63,6 +74,14 @@ function DeleteWorktreeFailureToastBody({
{translate('auto.components.sidebar.delete.worktree.flow.2b20ce87b3', 'Force Delete')}
</Button>
) : null}
{canWaiveArchiveHook ? (
<Button type="button" variant="destructive" size="sm" onClick={deleteAnyway}>
{translate(
'auto.components.sidebar.delete.worktree.failure.archive.waiver',
'Delete Anyway'
)}
</Button>
) : null}
</div>
</div>
)
@@ -74,8 +93,10 @@ export function showDeleteWorktreeFailureToast({
forceDeleteReason,
lockReason,
hasKnownChanges,
canWaiveArchiveHook,
onViewChanges,
onForceDelete,
onDeleteAnyway,
worktreeId,
worktreeName
}: DeleteWorktreeFailureToastOptions): void {
@@ -96,13 +117,16 @@ export function showDeleteWorktreeFailureToast({
<DeleteWorktreeFailureToastBody
description={toastCopy.description}
canForceDelete={canForceDelete}
canWaiveArchiveHook={canWaiveArchiveHook === true}
showViewChanges={!isLockedWorktreeRemovalError(error) || hasKnownChanges === true}
onViewChanges={onViewChanges}
onForceDelete={onForceDelete}
onDeleteAnyway={onDeleteAnyway}
toastId={id}
/>
),
duration: canForceDelete ? Infinity : 10000,
// A toast offering a destructive choice must not expire before the user reads the reason.
duration: canForceDelete || canWaiveArchiveHook === true ? Infinity : 10000,
dismissible: true
})
}
@@ -1,7 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionHostId } from '../../../../shared/execution-host'
type MockWorktreeDeleteState = {
isDeleting?: boolean
error?: string | null
canForceDelete?: boolean
forceDeleteReason?: 'dirty' | null
lockReason?: string | null
canWaiveArchiveHook?: boolean
executionHostId?: ExecutionHostId | null
}
const mocks = vi.hoisted(() => {
// Declared up here so the empty initialisers can be typed rather than asserted.
const gitStatusByWorktree: Record<string, unknown[]> = {}
const deleteStateByWorktreeId: Record<string, MockWorktreeDeleteState> = {}
const state = {
settings: { skipDeleteWorktreeConfirm: false },
worktreeMap: new Map<
@@ -35,18 +48,8 @@ const mocks = vi.hoisted(() => {
setRightSidebarTab: vi.fn(),
setRightSidebarOpen: vi.fn(),
removeWorktree: vi.fn().mockResolvedValue({ ok: true }),
gitStatusByWorktree: {} as Record<string, unknown[]>,
deleteStateByWorktreeId: {} as Record<
string,
{
isDeleting?: boolean
error?: string | null
canForceDelete?: boolean
forceDeleteReason?: 'dirty' | null
lockReason?: string | null
executionHostId?: ExecutionHostId | null
}
>
gitStatusByWorktree,
deleteStateByWorktreeId
}
return { state }
})
@@ -631,4 +634,42 @@ describe('delete worktree flow', () => {
description: 'Refresh Space and try again if the workspace list looks stale.'
})
})
// #19334: a waived delete is still a delete — the caller's bookkeeping has to hear about it, or a
// batch/Space-panel list keeps showing the workspace it just removed.
it('reports a Delete Anyway success to the caller like a force retry', async () => {
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
mocks.state.removeWorktree
.mockImplementationOnce(async () => {
mocks.state.deleteStateByWorktreeId['wt-1'] = {
isDeleting: false,
error: 'Archive hook failed for worktree: /w/one — exited 23.',
canForceDelete: false,
forceDeleteReason: null,
canWaiveArchiveHook: true
}
return { ok: false, error: 'Archive hook failed for worktree: /w/one — exited 23.' }
})
.mockResolvedValueOnce({ ok: true })
setWorktrees([{ id: 'wt-1', displayName: 'one' }])
const onDeleted = vi.fn()
expect(runWorktreeBatchDelete(['wt-1'], { onDeleted })).toBe(true)
await vi.waitFor(() => expect(showDeleteWorktreeFailureToast).toHaveBeenCalled())
const toastOptions = vi.mocked(showDeleteWorktreeFailureToast).mock.calls[0]?.[0]
expect(toastOptions?.canWaiveArchiveHook).toBe(true)
toastOptions?.onDeleteAnyway()
await vi.waitFor(() => {
// The waiver rides its own option; force stays whatever the original attempt used.
expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(
2,
{ id: 'wt-1', executionHostId: null },
false,
{ allowFailedArchiveHook: true }
)
expect(onDeleted).toHaveBeenCalledWith([{ id: 'wt-1', executionHostId: null }])
})
})
})
@@ -38,6 +38,54 @@ export function runWorktreeDeleteWithToast(
...(options.suppressPreservedBranchToast ? { suppressPreservedBranchToast: true } : {}),
...(options.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {})
}
// Both toast buttons do the same thing: recapture focus (the user may have navigated while the
// toast was open), retry with one waiver added, and report a success through `onForceDeleted` so
// the caller's bookkeeping runs. Only the waiver and the failure copy differ.
const retryFromToast = (retry: {
force: boolean
allowUnverifiedPtyStop?: boolean
allowFailedArchiveHook?: boolean
failedTitle?: string
withViewAction?: boolean
}): void => {
const commitRetryFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId)
const viewAction = retry.withViewAction
? {
action: {
label: translate('auto.components.sidebar.delete.worktree.flow.7488ed8711', 'View'),
onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId)
}
}
: {}
const failed = (description: string): void => {
toast.error(
retry.failedTitle ??
translate(
'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4',
'Failed to delete workspace'
),
{ description, ...viewAction }
)
}
useAppStore
.getState()
.removeWorktree(target, retry.force, {
...(retry.allowUnverifiedPtyStop ? { allowUnverifiedPtyStop: true } : {}),
...(retry.allowFailedArchiveHook ? { allowFailedArchiveHook: true } : {})
})
.then((result) => {
if (!result.ok) {
failed(result.error)
return
}
commitRetryFocus()
// "A retry started from this toast completed the delete" — callers hang their bookkeeping
// off it, so without this a batch or Space-panel delete keeps listing what it removed.
options.onForceDeleted?.(target)
})
.catch((err: unknown) => failed(err instanceof Error ? err.message : String(err)))
}
const removal =
Object.keys(removeOptions).length > 0
? removeWorktree(target, options.force === true, removeOptions)
@@ -71,60 +119,26 @@ export function runWorktreeDeleteWithToast(
showDeleteWorktreeFailureToast({
error: result.error,
canForceDelete,
canWaiveArchiveHook: state?.canWaiveArchiveHook === true,
forceDeleteReason: state?.forceDeleteReason ?? null,
lockReason: state?.lockReason ?? null,
hasKnownChanges,
onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId),
onForceDelete: () => {
// Recapture focus because the user may have navigated while the toast was open.
const commitForceFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId)
// The explicit Force Delete retry may waive an unverified PTY-stop proof.
const forceRemoval = useAppStore
.getState()
.removeWorktree(target, true, { allowUnverifiedPtyStop: true })
forceRemoval
.then((forceResult) => {
if (!forceResult.ok) {
toast.error(
translate(
'auto.components.sidebar.delete.worktree.flow.4f3876c0f5',
'Force delete failed'
),
{
description: forceResult.error,
action: {
label: translate(
'auto.components.sidebar.delete.worktree.flow.7488ed8711',
'View'
),
onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId)
}
}
)
return
}
commitForceFocus()
options.onForceDeleted?.(target)
})
.catch((err: unknown) => {
toast.error(
translate(
'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4',
'Failed to delete workspace'
),
{
description: err instanceof Error ? err.message : String(err),
action: {
label: translate(
'auto.components.sidebar.delete.worktree.flow.7488ed8711',
'View'
),
onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId)
}
}
)
})
},
// Why (#19334): re-runs the archive hook and waives the failure this time, so the waiver
// is an informed choice made after reading the refusal -- not something `force` implied.
onDeleteAnyway: () =>
retryFromToast({ force: options.force === true, allowFailedArchiveHook: true }),
// The explicit Force Delete retry may waive an unverified PTY-stop proof.
onForceDelete: () =>
retryFromToast({
force: true,
allowUnverifiedPtyStop: true,
failedTitle: translate(
'auto.components.sidebar.delete.worktree.flow.4f3876c0f5',
'Force delete failed'
),
withViewAction: true
}),
worktreeId,
worktreeName
})
+7 -1
View File
@@ -5802,6 +5802,11 @@
"unstoppedPtyLive": "This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold.",
"runningAgentSession": "Orca could not confirm every agent session in this workspace has closed, so it stopped before deleting any files. Use Force Delete to remove it anyway.",
"runningAgentSessionLive": "This workspace still has running agent sessions that Orca could not close, so it stopped before deleting any files. Force Delete will discard any work they hold."
},
"failure": {
"archive": {
"waiver": "Delete Anyway"
}
}
}
},
@@ -10953,7 +10958,8 @@
"orcaCloudSignOut": "Sign out",
"orcaCloudConnect": "Connect profile",
"orcaCloudRefresh": "Refresh status",
"orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build."
"orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build.",
"deleteAnywayClicked": "Delete Anyway clicked"
},
"EphemeralVmRecipeRow": {
"useInWorkspace": "Use in workspace"
+35
View File
@@ -131,3 +131,38 @@ describe('readIpcErrorDetail', () => {
).toBe('SSH connection failed: Relay package not found.')
})
})
// Why (#19334): a typed main-process error keeps its class name after the wrapper comes off, and
// the worktree-removal refusal is rendered to a user who does not care what the class was called.
describe('typed main-process errors', () => {
const wrapped = new Error(
"Error invoking remote method 'worktrees:remove': WorktreeArchiveHookFailedError: " +
'Archive hook failed for worktree: /w/feature — exited 23.\nbackup target unreachable'
)
it('drops the error class name along with the wrapper', () => {
expect(readIpcErrorDetail(wrapped)).toBe(
'Archive hook failed for worktree: /w/feature — exited 23.\nbackup target unreachable'
)
})
it('keeps the detail lines a refusal needs, unlike the clamped read', () => {
// The hook's own output is the actionable part of an archive refusal, so the unclamped read
// has to survive the newline that `readIpcErrorMessage` deliberately cuts at.
expect(readIpcErrorMessage(wrapped)).toBe(
'Archive hook failed for worktree: /w/feature — exited 23.'
)
})
it('leaves a renderer-local error its class name, having unwrapped nothing', () => {
expect(readIpcErrorDetail(new Error('TypeError: x is not a function'))).toBe(
'TypeError: x is not a function'
)
})
it('does not mistake an errno prefix for a class name', () => {
expect(
readIpcErrorDetail(new Error("Error occurred in handler for 'fs:read': EACCES: denied"))
).toBe('EACCES: denied')
})
})
+15 -1
View File
@@ -1,6 +1,10 @@
// Unanchored so caller-owned context around an Electron wrapper survives.
const IPC_INVOKE_PREFIX = /Error invoking remote method '[^']*':\s*(?:Error:\s*)?/
const IPC_HANDLER_PREFIX = /Error occurred in handler for '[^']*':\s*(?:Error:\s*)?/
// Why (#19334): once the wrapper is gone, a typed main-process error still leads with its class
// name — "WorktreeArchiveHookFailedError: Archive hook failed for worktree: …". That is noise to
// someone reading a toast, and it pushes the sentence that matters off the first line.
const ERROR_CLASS_PREFIX = /^(?:[A-Za-z_$][\w$]*)?Error:\s*/
function unwrapIpcErrorMessage(message: string): string | undefined {
const detail = message.replace(IPC_INVOKE_PREFIX, '').replace(IPC_HANDLER_PREFIX, '').trim()
@@ -11,7 +15,17 @@ export function compactIpcErrorMessage(message: string): string | undefined {
return unwrapIpcErrorMessage(message)?.split('\n')[0]?.trim() || undefined
}
export function readIpcErrorDetail(error: unknown): string | undefined {
return error instanceof Error ? unwrapIpcErrorMessage(error.message) : undefined
if (!(error instanceof Error)) {
return undefined
}
const detail = unwrapIpcErrorMessage(error.message)
if (!detail) {
return undefined
}
// Only strip the class name off something we actually unwrapped, so a renderer-local
// `TypeError: …` keeps the prefix that tells you what it was.
const wrapped = IPC_INVOKE_PREFIX.test(error.message) || IPC_HANDLER_PREFIX.test(error.message)
return wrapped ? detail.replace(ERROR_CLASS_PREFIX, '').trim() || undefined : detail
}
export function readIpcErrorMessage(error: unknown): string | undefined {
@@ -377,18 +377,34 @@ describe('removeWorktree cascade', () => {
})
})
it('offers force delete for Electron-wrapped local dirty preflight errors', async () => {
// Why a table (#19334): these three differ only in the wrapped message and the classification it
// earns. The shared body is what matters — the IPC wrapper is stripped for display while
// classification still reads the wrapped input.
it.each([
[
'offers force delete for Electron-wrapped local dirty preflight errors',
"Error invoking remote method 'worktrees:remove': Error: Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt",
'Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt',
{ canForceDelete: true, forceDeleteReason: 'dirty' }
],
[
'offers force delete when Git already removed an unregistered worktree',
"Error invoking remote method 'worktrees:remove': Error: Worktree is no longer registered with Git and its directory is already gone.",
'Worktree is no longer registered with Git and its directory is already gone.',
{ canForceDelete: true, forceDeleteReason: 'missing-registration' }
],
[
'does not offer force delete when Electron wraps SSH filesystem provider failures',
"Error invoking remote method 'worktrees:remove': Error: SSH filesystem provider unavailable",
'SSH filesystem provider unavailable',
{ canForceDelete: false, forceDeleteReason: null }
]
])('%s', async (_title, wrapped, displayed, classification) => {
const store = createTestStore()
const worktreeId = 'repo1::/workspace/feature-wt'
const error =
"Error invoking remote method 'worktrees:remove': Error: Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt"
mockApi.worktrees.remove.mockRejectedValueOnce(new Error(error))
mockApi.worktrees.remove.mockRejectedValueOnce(new Error(wrapped))
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
},
worktreesByRepo: { repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })] },
tabsByWorktree: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {}
@@ -396,12 +412,11 @@ describe('removeWorktree cascade', () => {
const result = await store.getState().removeWorktree({ id: worktreeId, executionHostId: null })
expect(result).toEqual({ ok: false, error })
expect(result).toEqual({ ok: false, error: displayed })
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
isDeleting: false,
error,
canForceDelete: true,
forceDeleteReason: 'dirty'
error: displayed,
...classification
})
})
@@ -462,34 +477,6 @@ describe('removeWorktree cascade', () => {
})
})
it('offers force delete when Git already removed an unregistered worktree', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/workspace/deleted-wt'
const error =
"Error invoking remote method 'worktrees:remove': Error: Worktree is no longer registered with Git and its directory is already gone."
mockApi.worktrees.remove.mockRejectedValueOnce(new Error(error))
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
},
tabsByWorktree: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {}
})
const result = await store.getState().removeWorktree({ id: worktreeId, executionHostId: null })
expect(result).toEqual({ ok: false, error })
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
isDeleting: false,
error,
canForceDelete: true,
forceDeleteReason: 'missing-registration'
})
})
it('sets canForceDelete=false when force=true removal fails', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
@@ -579,34 +566,6 @@ describe('removeWorktree cascade', () => {
expect(store.getState().deleteStateByWorktreeId[worktreeId]?.canForceDelete).toBe(false)
})
it('does not offer force delete when Electron wraps SSH filesystem provider failures', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
const error =
"Error invoking remote method 'worktrees:remove': Error: SSH filesystem provider unavailable"
mockApi.worktrees.remove.mockRejectedValueOnce(new Error(error))
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
},
tabsByWorktree: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {}
})
const result = await store.getState().removeWorktree({ id: worktreeId, executionHostId: null })
expect(result).toEqual({ ok: false, error })
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
isDeleting: false,
error,
canForceDelete: false,
forceDeleteReason: null
})
})
it.each([
'Could not connect to the remote Orca runtime.',
'Remote Orca runtime closed the connection.',
@@ -617,6 +576,8 @@ describe('removeWorktree cascade', () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
const error = `Error invoking remote method 'runtime-environments:call': Error: ${runtimeFailure}`
// The wrapper is stripped for display; the runtime failure text is what the user sees.
const displayed = runtimeFailure
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => {
const compatibility = createCompatibleRuntimeStatusResponseIfNeeded(args)
@@ -648,10 +609,10 @@ describe('removeWorktree cascade', () => {
.getState()
.removeWorktree({ id: worktreeId, executionHostId: null })
expect(result).toEqual({ ok: false, error })
expect(result).toEqual({ ok: false, error: displayed })
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
isDeleting: false,
error,
error: displayed,
canForceDelete: false,
forceDeleteReason: null
})
@@ -10,6 +10,8 @@ export type WorktreeDeleteState = {
canForceDelete: boolean
forceDeleteReason: WorktreeForceDeleteReason | null
lockReason?: string | null
/** The removal was refused by a failed archive hook, so "Delete anyway" is offered (#19334). */
canWaiveArchiveHook?: boolean
}
export type WorktreeDeleteStateTarget = Pick<Worktree, 'id' | 'hostId'>
@@ -9,6 +9,9 @@ export type RemoveWorktreeOptions = {
// Why (#11960): only an explicit Force Delete waives the proof that every
// PTY stopped; `force` alone is set by the ordinary delete confirmation.
allowUnverifiedPtyStop?: boolean
// Why (#19334): waives a FAILED archive hook. Set only by the explicit "Delete anyway" retry
// after the user has seen the refusal -- never by the ordinary confirmation, never by `force`.
allowFailedArchiveHook?: boolean
snapshotPruneBatchId?: string
/** Fresh cleanup-scan evidence for a same-id owner not represented in the catalog. */
sameIdSurvivingHostId?: ExecutionHostId
@@ -601,6 +601,8 @@ describe('worktree remote runtime mutations', () => {
force: undefined,
// Why (#11960): an ordinary remove never waives the PTY-stop proof.
allowUnverifiedPtyStop: false,
// Why (#19334): nor a failed archive hook — only the explicit "Delete anyway" retry does.
allowFailedArchiveHook: false,
skipArchive: false
})
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
@@ -36,6 +36,7 @@ export async function dispatchWorktreeRemoval(args: {
hostId,
force,
allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true,
allowFailedArchiveHook: options?.allowFailedArchiveHook === true,
skipArchive,
...snapshotPruneBatch
})
@@ -50,6 +51,9 @@ export async function dispatchWorktreeRemoval(args: {
...(effectiveHostId ? { hostId: effectiveHostId } : {}),
force,
allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true,
// Why only when set, unlike the IPC branch: this crosses a version boundary, and a host
// that predates the gate drops unknown params silently. Send it when it means something.
...(options?.allowFailedArchiveHook === true ? { allowFailedArchiveHook: true } : {}),
runHooks: !skipArchive
},
{ timeoutMs: 60_000 }
@@ -7,6 +7,8 @@ import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client'
import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals'
import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
import { readIpcErrorDetail } from '@/lib/ipc-error'
import { isArchiveHookRemovalError } from '../../../../../../shared/worktree/archive-hook-removal-gate'
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
import {
resolveWorktreeOperationRouteResult,
@@ -297,13 +299,18 @@ export function createRemoveWorktree(
} catch (err) {
// Why: git refusing a non-force delete for dirty/untracked files is a handled user decision, not an app error.
console.warn('Failed to remove worktree:', err)
const error = err instanceof Error ? err.message : String(err)
// The raw message arrives wrapped in Electron's IPC channel and class names; this string is
// read by a user in a toast, and the refusal sentence has to lead it.
const error = readIpcErrorDetail(err) ?? (err instanceof Error ? err.message : String(err))
const forceDeleteReason = classifyWorktreeForceDeleteReason(
error,
force,
options?.allowUnverifiedPtyStop === true
)
const locked = isLockedWorktreeRemovalError(error)
// Why (#19334): the refusal is the only failure a retry can clear by waiving rather than by
// fixing state, so the toast needs to know it may offer that choice.
const canWaiveArchiveHook = isArchiveHookRemovalError(error)
set((s) => ({
deleteStateByWorktreeId: {
...s.deleteStateByWorktreeId,
@@ -313,6 +320,7 @@ export function createRemoveWorktree(
error,
canForceDelete: forceDeleteReason !== null,
forceDeleteReason,
...(canWaiveArchiveHook ? { canWaiveArchiveHook: true } : {}),
...(locked ? { lockReason: getLockedWorktreeRemovalReason(error) } : {})
}
}
+1
View File
@@ -3,6 +3,7 @@ export const CLI_GLOBAL_FLAGS: readonly string[] = ['help', 'json', ...CLI_GLOBA
export const CLI_BOOLEAN_FLAGS = new Set([
'all',
'allow-failed-archive-hook',
'attachments',
'children',
'comments',
+8
View File
@@ -114,6 +114,13 @@ export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-comman
// status.worktreeCreateIdempotency carries the optional host retention policy.
export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
'worktree.create-idempotency.v1' as const
// Why (#19334): "accepts --run-hooks" and "refuses to delete when the archive hook fails" were
// indistinguishable from the outside — both take the flag and behave identically on success, so
// the only way to tell an unfixed host apart was to fail a hook and see whether the checkout
// survived. Lifecycle integrations keep teardown evidence inside the checkout and cannot risk
// that. Advertised unconditionally: every build carrying this constant has the gate.
export const WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY =
'worktree.archive-failure-blocking.v1' as const
export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const
export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const
// Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised.
@@ -283,6 +290,7 @@ export const RUNTIME_CAPABILITIES = [
TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY,
WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY,
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
+4 -1
View File
@@ -171,7 +171,10 @@ export const WorktreeRemove = WorktreeSelector.extend({
// desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop
// waiver travels on its own field.
allowUnverifiedPtyStop: OptionalBoolean,
runHooks: OptionalBoolean
runHooks: OptionalBoolean,
// Why (#19334): a failed archive hook blocks removal. This waives that refusal and is recorded
// in the result; it is NOT `force`, and it does not decide whether the hook runs.
allowFailedArchiveHook: OptionalBoolean
})
export const WorktreeForceDeleteBranch = WorktreeSelector.extend({
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import {
RUNTIME_CAPABILITIES,
WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY
} from '../protocol-version'
// Why (#19334): the reporter's integration (Harbour) keeps its teardown ownership evidence inside
// the checkout, so it must know *before* removing anything whether this host refuses to delete on a
// failed archive hook. It cannot probe for that — probing means risking the data loss.
describe('worktree.archive-failure-blocking.v1', () => {
it('uses the id the reporting integration already codes against', () => {
expect(WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY).toBe(
'worktree.archive-failure-blocking.v1'
)
})
it('is advertised by every build that carries the gate', () => {
expect(RUNTIME_CAPABILITIES).toContain(WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY)
})
})
@@ -0,0 +1,109 @@
// Why (#19334): the archive hook is a user's last chance to save work off a checkout Orca is
// about to delete. A failed hook used to be logged and stepped over, so the delete went ahead
// with nothing archived. It is a precondition, evaluated before any stop/delete mutation.
/** RPC/CLI error code for a removal refused because the repo's archive hook did not succeed. */
export const ARCHIVE_HOOK_FAILED_REMOVAL_CODE = 'worktree_archive_hook_failed'
export const ARCHIVE_HOOK_FAILED_REMOVAL_PREFIX = 'Archive hook failed for worktree:'
// One string, three surfaces: the CLI, RPC callers, and the desktop toast that now carries its own
// Delete Anyway button. Naming only the CLI flag sent desktop users to a terminal for a button that
// was six inches away, so both affordances are named and neither is presented as the only one.
export const ARCHIVE_HOOK_OVERRIDE_HINT =
'Nothing was stopped, deleted or deregistered. Fix the hook and retry, or delete anyway with an explicit waiver — "Delete Anyway" in the app, or --allow-failed-archive-hook on the CLI.'
/**
* `exited` means the host reported a non-zero exit for this hook run. `unverifiable` covers every
* case where the hook's outcome was never observed — spawn failure, timeout, lost contact with the
* execution host. Loss of contact is never evidence that the hook passed, so both block removal.
* Vocabulary is deliberately the `UnstoppedPtyVerdict` spelling; see docs/reference/ssh-execution-boundary.md.
*/
export type ArchiveHookOutcome = 'exited' | 'unverifiable'
export type ArchiveHookFailure = {
worktreePath: string
outcome: ArchiveHookOutcome
/** Only ever set for `exited` — an absent code is not a zero code. */
exitCode?: number
output: string
}
/** What a caller sees when the failure was explicitly overridden instead of blocking. */
export type ArchiveHookOverride = ArchiveHookFailure & { overridden: true }
export class WorktreeArchiveHookFailedError extends Error {
readonly code = ARCHIVE_HOOK_FAILED_REMOVAL_CODE
readonly data: ArchiveHookFailure
constructor(failure: ArchiveHookFailure) {
super(formatArchiveHookFailure(failure))
this.name = 'WorktreeArchiveHookFailedError'
this.data = failure
}
}
function describeArchiveHookVerdict(failure: ArchiveHookFailure): string {
return failure.outcome === 'exited'
? `exited ${failure.exitCode}`
: 'outcome unverifiable (the hook never reported an exit)'
}
export function formatArchiveHookFailure(failure: ArchiveHookFailure): string {
const output = failure.output.trim()
return [
`${ARCHIVE_HOOK_FAILED_REMOVAL_PREFIX} ${failure.worktreePath} — ${describeArchiveHookVerdict(failure)}.`,
ARCHIVE_HOOK_OVERRIDE_HINT,
...(output ? [output] : [])
].join(' ')
}
/**
* The waived case says the opposite of the refusal: the removal DID go ahead. Reusing
* `formatArchiveHookFailure` here printed "Nothing was stopped, deleted or deregistered" directly
* after deleting the checkout.
*/
export function formatArchiveHookOverride(override: ArchiveHookOverride): string {
const output = override.output.trim()
return [
`Archive hook failed for worktree: ${override.worktreePath} — ${describeArchiveHookVerdict(override)}.`,
'Deleted anyway because the failure was explicitly waived; nothing was archived.',
...(output ? [output] : [])
].join(' ')
}
/**
* Narrow an unknown rejection to the typed refusal, or rethrow it. This is the branch a real
* caller writes, so tests asserting on a refusal should go through it rather than re-deriving it.
*/
export function asArchiveHookRefusal(error: unknown): WorktreeArchiveHookFailedError {
if (error instanceof WorktreeArchiveHookFailedError) {
return error
}
throw error
}
/** Recognise the refusal on a surface that only has the message, e.g. a renderer toast. */
export function isArchiveHookRemovalError(error: string): boolean {
return error.includes(ARCHIVE_HOOK_FAILED_REMOVAL_PREFIX)
}
/** Shape both the local and the SSH archive runners answer with. */
export type ArchiveHookRunResult = {
success: boolean
output: string
/** Omitted whenever no exit was observed, which classifies the failure as `unverifiable`. */
exitCode?: number
}
export function classifyArchiveHookFailure(
worktreePath: string,
result: ArchiveHookRunResult
): ArchiveHookFailure {
return {
worktreePath,
outcome: typeof result.exitCode === 'number' ? 'exited' : 'unverifiable',
...(typeof result.exitCode === 'number' ? { exitCode: result.exitCode } : {}),
output: result.output
}
}
+3
View File
@@ -1,4 +1,5 @@
import type { ExecutionHostId } from '../execution-host'
import type { ArchiveHookOverride } from './archive-hook-removal-gate'
import type { WorkspaceSource } from '../workspace-source'
import type { TaskSourceContext } from '../task-source-context'
import type { WorkspaceKey } from '../folder-workspace-types'
@@ -207,6 +208,8 @@ export type PreservedWorktreeBranch = {
export type RemoveWorktreeResult = {
preservedBranch?: PreservedWorktreeBranch
/** Present only when a FAILED archive hook was explicitly waived for this removal (#19334). */
archiveHookOverride?: ArchiveHookOverride
}
export type ForceDeleteWorktreeBranchResult = {