mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(worktree): tell the truth about live PTYs, and offer force for a wedged sweep Two gaps in the #11960 force path: The delete toast described every unstopped-PTY failure as "could not confirm every terminal has exited", including the case where verification positively watched them running. Force Delete proceeds either way, so the user was being asked to waive a doubt that did not exist while a running agent's uncommitted work died with it. The live verdict now gets copy that says so. A sweep that rejects before any per-PTY verdict exists (wedged daemon, dropped SSH channel) fails with a teardown-timeout message that the force classifier did not recognise, so no Force Delete button appeared — the exact dead end #11960 set out to remove. That error now carries the shared prefix and classifies. * fix(worktree): close the sweep-rejection wedge and stop racing the delete Review of #12394 found the fix covered only half the wedge it named, and routed users into a force path whose own safety comment was untrue. 1. Only the outer deadline was classifiable. When a provider *rejects* the sweep — dropped SSH channel, erroring daemon — settleBeforeDeadline rejects with the provider's original error, which carries no marker, so classifyWorktreeForceDeleteReason still returned null and no Force Delete button rendered. That is the exact case #11960 named. A rejected sweep on the destructive path is now reworded through the existing unstopped-PTY prefix (provider text preserved, original kept as `cause`), so old and new clients alike classify it as 'unstopped-pty'. 2. Force could delete files while a sweep was still running. The deadline rejects without cancelling run(), so allSettled resolved with shutdown() still in flight — by construction the deadline error can only fire while something is in flight. Force then deleted the directory a live PTY still held open (EBUSY / half-delete on Windows and WSL). Sweeps are now tracked so the forced path waits for the abandoned work, bounded by a 2s grace; force never wedges, and when the grace expires the warning says handles may outlive the delete instead of implying the sweep finished. 3. The toast test named for the classifier passed the reason in as a literal, so it never exercised it. It now derives the reason exactly as the store does, and fails against main. 4. Added the missing unstoppedPtyLive key to the English catalog. 5. isProvenLivePtyRemovalError anchored the 'still live:' marker to the detail separator, so a worktree path can no longer spell out a live verdict and flip the toast to the destructive copy. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
125 lines
5.2 KiB
TypeScript
125 lines
5.2 KiB
TypeScript
import type { GitWorktreeInfo } from './types'
|
|
|
|
export const LOCKED_WORKTREE_REMOVAL_PREFIX = 'Worktree is locked by Git.'
|
|
|
|
export const UNSTOPPED_PTY_REMOVAL_PREFIX = 'Failed to physically stop every PTY for worktree:'
|
|
|
|
// Why (#11960): the desktop force affordance is driven entirely by the classifier
|
|
// below, so this hint and its matcher must stay in the same file — a message that
|
|
// tells the user to force-delete while the UI hides the button is the same dead end.
|
|
export const WORKTREE_TEARDOWN_FORCE_HINT = 'Retry with force delete (--force) to remove it anyway.'
|
|
|
|
export type WorktreeForceDeleteReason =
|
|
| 'dirty'
|
|
| 'orphan-directory'
|
|
| 'missing-registration'
|
|
| 'unstopped-pty'
|
|
|
|
// Why: everything before this separator is the worktree id — a user-chosen filesystem path.
|
|
// Only the detail after it is Orca's own wording, so verdict matchers anchor on the boundary
|
|
// rather than scanning the whole message and letting a path spell out a verdict.
|
|
export const UNSTOPPED_PTY_DETAIL_SEPARATOR = ' — '
|
|
|
|
// Why: verification distinguishes a PTY it watched stay alive from one it could not reach,
|
|
// and the delete toast must not flatten the two — a user waiving "we could not confirm" is
|
|
// making a different decision than one killing a terminal Orca just saw running. The marker
|
|
// and its matcher stay together for the same reason the force hint does.
|
|
export const UNSTOPPED_PTY_LIVE_DETAIL_PREFIX = 'still live:'
|
|
|
|
// Why (#11960): a sweep that never answers wedges removal exactly like a stop that could not
|
|
// be proven, and the waiver clears both — but this error carries different words, so without
|
|
// its own matcher the force affordance stayed hidden for the very case it was added for.
|
|
export const WORKTREE_TEARDOWN_TIMEOUT_PREFIX = 'Timed out waiting for physical PTY teardown:'
|
|
|
|
export function isUnstoppedPtyRemovalError(error: string): boolean {
|
|
return (
|
|
error.includes(UNSTOPPED_PTY_REMOVAL_PREFIX) || error.includes(WORKTREE_TEARDOWN_TIMEOUT_PREFIX)
|
|
)
|
|
}
|
|
|
|
/** True only when verification positively observed the PTYs still running. */
|
|
export function isProvenLivePtyRemovalError(error: string): boolean {
|
|
return (
|
|
isUnstoppedPtyRemovalError(error) &&
|
|
error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX}`)
|
|
)
|
|
}
|
|
|
|
export function createLockedWorktreeRemovalError(lockReason?: string): Error {
|
|
const reason = lockReason?.trim()
|
|
return new Error(
|
|
reason
|
|
? `${LOCKED_WORKTREE_REMOVAL_PREFIX} Lock reason: ${reason}. Run git worktree unlock <worktree-path> from its repository, then retry deletion.`
|
|
: `${LOCKED_WORKTREE_REMOVAL_PREFIX} Run git worktree unlock <worktree-path> from its repository, then retry deletion.`
|
|
)
|
|
}
|
|
|
|
export function assertWorktreeUnlockedForRemoval(
|
|
worktree: Pick<GitWorktreeInfo, 'locked' | 'lockReason'> | undefined
|
|
): void {
|
|
if (worktree?.locked) {
|
|
throw createLockedWorktreeRemovalError(worktree.lockReason)
|
|
}
|
|
}
|
|
|
|
export function isLockedWorktreeRemovalError(error: string): boolean {
|
|
return (
|
|
error.includes(LOCKED_WORKTREE_REMOVAL_PREFIX) ||
|
|
error.includes('cannot remove a locked working tree')
|
|
)
|
|
}
|
|
|
|
export function getLockedWorktreeRemovalReason(error: string): string | null {
|
|
const prefixIndex = error.indexOf(`${LOCKED_WORKTREE_REMOVAL_PREFIX} Lock reason: `)
|
|
if (prefixIndex === -1) {
|
|
return null
|
|
}
|
|
const reasonStart = prefixIndex + `${LOCKED_WORKTREE_REMOVAL_PREFIX} Lock reason: `.length
|
|
const recoverySuffix =
|
|
'. Run git worktree unlock <worktree-path> from its repository, then retry deletion.'
|
|
const suffixIndex = error.indexOf(recoverySuffix, reasonStart)
|
|
const reason = error.slice(reasonStart, suffixIndex === -1 ? undefined : suffixIndex).trim()
|
|
return reason || null
|
|
}
|
|
|
|
const FORMATTED_DIRTY_WORKTREE_REMOVAL_PATTERN =
|
|
/Failed to delete worktree at [^\n]*\.\s*(?:(?:[MADRCUT][ MADRCUT]| [MADRCUT]|\?\?)\s+\S)/
|
|
|
|
export function classifyWorktreeForceDeleteReason(
|
|
error: string,
|
|
force = false,
|
|
allowUnverifiedPtyStop = false
|
|
): WorktreeForceDeleteReason | null {
|
|
if (isLockedWorktreeRemovalError(error)) {
|
|
// Why: a Git lock can represent an external safety contract. It must be
|
|
// unlocked explicitly rather than folded into Orca's dirty-file force path.
|
|
return null
|
|
}
|
|
// Why (#11960): this must be decided before the `force` guard below. The ordinary
|
|
// delete confirmation already passes force:true to skip the dirty-file prompt, but
|
|
// it does NOT waive PTY-stop proof — so `force` alone is no evidence that the user
|
|
// has already spent this escape hatch. Only the waiver itself is.
|
|
if (isUnstoppedPtyRemovalError(error)) {
|
|
return allowUnverifiedPtyStop ? null : 'unstopped-pty'
|
|
}
|
|
if (force) {
|
|
return null
|
|
}
|
|
if (error.includes('Worktree is no longer registered with Git but its directory remains')) {
|
|
return 'orphan-directory'
|
|
}
|
|
if (
|
|
error.includes('Worktree is no longer registered with Git and its directory is already gone')
|
|
) {
|
|
return 'missing-registration'
|
|
}
|
|
if (
|
|
error.includes('Worktree has uncommitted or untracked changes') ||
|
|
error.includes('contains modified or untracked files') ||
|
|
FORMATTED_DIRTY_WORKTREE_REMOVAL_PATTERN.test(error)
|
|
) {
|
|
return 'dirty'
|
|
}
|
|
return null
|
|
}
|