From 0cbd23aaf4bffc28375875396c572cde1499eeb7 Mon Sep 17 00:00:00 2001 From: Hyunwoo Jung Date: Thu, 11 Jun 2026 15:41:15 +0900 Subject: [PATCH] fix(app): kill processes inside a worktree before removing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `removeWorktree` ran `git worktree remove --force` while a happy session could still be cwd'd inside the worktree — typically a session that was orphaned when its daemon restarted, so R2/daemon-driven stop never reached it. `--force` then deleted the directory out from under the live process, which kept its server socket open and re-reported the session as `active` indefinitely. The session could never be archived from the app: every `active:false` was immediately overwritten by the orphan's next heartbeat. Stop those processes before deleting the directory. `killProcessesInWorktree` matches by working directory (Linux: /proc//cwd, macOS: lsof), anchored to the worktree dir or a subdir of it so siblings sharing a name prefix are spared, and canonicalises the path so a symlinked parent still matches. SIGTERM lets the happy CLI shut its session down cleanly. Best-effort — a failure here never blocks the removal. --- packages/happy-app/sources/utils/worktree.ts | 41 ++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/happy-app/sources/utils/worktree.ts b/packages/happy-app/sources/utils/worktree.ts index 7644fd03cf..8fe32831e6 100644 --- a/packages/happy-app/sources/utils/worktree.ts +++ b/packages/happy-app/sources/utils/worktree.ts @@ -151,6 +151,43 @@ export async function listWorktrees( return worktrees; } +/** + * Kill any process whose working directory is inside the worktree. + * + * `git worktree remove --force` deletes the directory even while a process is + * still cwd'd into it — most often a happy session that got orphaned when its + * daemon restarted. That orphan then lives on inside a now-deleted directory, + * holding its server socket open and re-reporting the session as `active` + * forever, so the session can never be archived from the app. We therefore + * stop those processes *before* removing the directory. + * + * Portable across the machine's OS: Linux reads /proc//cwd, macOS falls + * back to lsof (resolved by absolute path so a stripped PATH still finds it). + * The worktree path is canonicalised (pwd -P) so a symlinked parent still + * matches, and the match is anchored to the worktree dir or a subdir of it — + * never a sibling that merely shares a name prefix. SIGTERM lets the happy CLI + * shut its session down cleanly. Entirely best-effort: a failure here must + * never block the worktree removal itself. + */ +async function killProcessesInWorktree( + machineId: string, + worktreePath: string, + cwd: string +): Promise { + const script = [ + `WT='${worktreePath}'`, + `WT=$(cd "$WT" 2>/dev/null && pwd -P || printf %s "$WT")`, + `if [ -d /proc ]; then`, + ` for d in /proc/[0-9]*; do c=$(readlink "$d/cwd" 2>/dev/null) || continue; case "$c" in "$WT"|"$WT"/*) kill "$(basename "$d")" 2>/dev/null || true;; esac; done`, + `else`, + ` L=$(command -v lsof || echo /usr/sbin/lsof)`, + ` "$L" -d cwd -Fpn 2>/dev/null | awk -v wt="$WT" '/^p/{pid=substr($0,2)} /^n/{p=substr($0,2); if(p==wt||index(p,wt"/")==1)print pid}' | while read -r pid; do kill "$pid" 2>/dev/null || true; done`, + `fi`, + `true`, + ].join('\n'); + await machineBash(machineId, script, cwd).catch(() => {}); +} + export async function removeWorktree( machineId: string, worktreePath: string @@ -161,6 +198,10 @@ export async function removeWorktree( } const basePath = worktreePath.slice(0, idx); + // Stop processes still running inside the worktree before --force deletes + // it out from under them (see killProcessesInWorktree). + await killProcessesInWorktree(machineId, worktreePath, basePath); + const result = await machineBash( machineId, `git worktree remove ${worktreePath} --force`,