From aa14e80845cdd0401475ba895368adc14f9ed711 Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 15:13:04 +0200 Subject: [PATCH 01/11] fix(bin): harden worker lifecycle cleanup and relaunch --- bin/fm-backend.sh | 18 +++ bin/fm-brief.sh | 28 +++- bin/fm-control.sh | 47 +++++- bin/fm-spawn.sh | 57 +++++-- bin/fm-tangle-lib.sh | 17 +++ bin/fm-teardown.sh | 176 ++++++++++++++++++---- docs/agent-control.md | 7 +- docs/architecture.md | 3 +- tests/fm-backend.test.sh | 27 ++++ tests/fm-control-herdr-smoke.test.sh | 49 +++++- tests/fm-control-relaunch.test.sh | 68 ++++++++- tests/fm-tangle-guard.test.sh | 95 +++++++++++- tests/fm-teardown-endpoint-safety.test.sh | 56 +++++++ tests/fm-teardown.test.sh | 92 ++++++++++- 14 files changed, 662 insertions(+), 78 deletions(-) diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 3233921e071..1aa7d4b7826 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -761,6 +761,24 @@ fm_backend_kill() { # esac } +# fm_backend_current_path: the live occupied working directory of a running +# target, or nonzero when the backend cannot prove it without interacting with +# the agent. tmux and Herdr expose the foreground process cwd passively. Zellij +# and cmux only support an active shell probe, which is safe during pre-agent +# spawn discovery but would submit `pwd` into a running agent during relaunch; +# Orca exposes no corresponding proof. +fm_backend_current_path() { # + local backend=$1 + shift + fm_backend_source "$backend" || return 1 + case "$backend" in + tmux) fm_backend_tmux_current_path "$@" ;; + herdr) fm_backend_herdr_current_path "$@" ;; + zellij|cmux|orca) return 1 ;; + *) echo "error: no current-path implementation for backend '$backend'" >&2; return 1 ;; + esac +} + fm_backend_remove_worktree() { # local backend=$1 shift diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 5cb0a80f5e3..4eb1d76d520 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -48,6 +48,11 @@ # to launch a ship task whose explicit --mode disagrees, so an adjusted brief and the # recorded task metadata cannot drift apart. # Ship briefs begin with a worktree-isolation assertion before the branch step. +# Project paths are resolved before that assertion is written: `.` becomes the +# repository's primary working tree (fm-tangle-lib.sh), so a linked firstmate +# worktree is not described as the primary and a treehouse copy is not told to +# stop. The assertion uses a Git discriminant (git-dir vs git-common-dir); +# equality of pwd and git-toplevel does not prove isolation. # --mode is refused on scout and secondmate scaffolds: a scout's deliverable is a # report rather than a merge, and a charter is not a delivery contract. # There is no --yolo flag here. The worker never owns merge decisions, so yolo is @@ -90,6 +95,8 @@ esac . "$SCRIPT_DIR/fm-classify-lib.sh" # shellcheck source=bin/fm-dod-lib.sh . "$SCRIPT_DIR/fm-dod-lib.sh" +# shellcheck source=bin/fm-tangle-lib.sh +. "$SCRIPT_DIR/fm-tangle-lib.sh" PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} resolve_directory_input() { @@ -310,6 +317,18 @@ exit 0 fi REPO=${POS[1]} +REPO_PRIMARY= +REPO_LABEL=$REPO +if [ -d "$REPO" ] || [ "$REPO" = . ] || [ "$REPO" = .. ]; then + if REPO_ABS=$(CDPATH='' cd -- "$REPO" 2>/dev/null && pwd -P); then + REPO_PRIMARY=$(fm_git_primary_workdir "$REPO_ABS" 2>/dev/null) || REPO_PRIMARY=$REPO_ABS + REPO_LABEL=$(basename "$REPO_PRIMARY") + fi +fi +PRIMARY_CLAUSE= +if [ -n "$REPO_PRIMARY" ]; then + PRIMARY_CLAUSE=", which is \`$REPO_PRIMARY\`" +fi if [ "$HERDR_LAB" -eq 1 ]; then HERDR_LAB_HELPER=$(shell_quote "$FM_ROOT/bin/fm-herdr-lab.sh") @@ -446,11 +465,12 @@ $TASK_SECTION $HERDR_SECTION # Setup -You are in a disposable git worktree of $REPO, at a detached HEAD on a clean default branch. +You are in a disposable git worktree of $REPO_LABEL, at a detached HEAD on a clean default branch. -**Verify isolation before anything else.** Run \`pwd -P\` and \`git rev-parse --show-toplevel\`; both must resolve to the disposable task worktree you were launched in, such as a treehouse pool path or an Orca-managed worktree, not the primary checkout firstmate operates from. -The path check is authoritative: \`git rev-parse --git-dir\` and \`git rev-parse --git-common-dir\` can help inspect the repo, but they do not prove you are outside the primary checkout. -If the top-level path is the primary checkout or not the worktree you were launched in, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop. +**Verify isolation before anything else.** Run \`pwd -P\`. It must be this disposable task worktree (a treehouse pool path or an Orca-managed worktree), not the project's primary checkout${PRIMARY_CLAUSE}. +Equality of \`pwd\` and \`git rev-parse --show-toplevel\` does not prove isolation: both name the current worktree root in the primary checkout and in a linked worktree. +A linked worktree has a real Git discriminant: \`git rev-parse --absolute-git-dir\` differs from \`git rev-parse --path-format=absolute --git-common-dir\`. Those paths are equal only in the primary checkout. +If \`pwd -P\` is the primary checkout, or those two Git directories are equal, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop. 1. First action: create your branch: \`git checkout -b fm/$ID\`$SETUP2 diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 21146188416..46ec05ccf89 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -46,12 +46,15 @@ # inherits the local copy but none of the conversation; a # secondmate reconciles its own home's records at startup, so its # standing charter is never rewritten. -# Records a durable checkpoint and that note, exits the old agent, -# then delegates the launch to its single owner, -# bin/fm-spawn.sh --relaunch. A failure before publication keeps -# the prior durable record in place and reports the concrete -# state; it never leaves a half-transitioned task claiming to be -# running. +# Records a durable checkpoint and that note, verifies the live +# occupied directory is the recorded worktree (Herdr's frozen +# launch cwd is not that proof), exits the old agent, then +# delegates the launch to its single owner, +# bin/fm-spawn.sh --relaunch. An unverifiable or mismatched live +# cwd refuses before the agent is stopped. A failure before +# publication keeps the prior durable record in place and reports +# the concrete state; it never leaves a half-transitioned task +# claiming to be running. # # Teardown and discard are NOT verbs here and never will be. `exit` stops an # agent and preserves everything else; removing a worktree, killing an @@ -684,6 +687,37 @@ resolve_relaunch_profile() { fi } +# require_relaunch_occupied_worktree: prove, before the running agent is +# stopped, that the live shell occupies the recorded worktree. Herdr's +# pane.cwd is the launch directory and does not update; the backend current- +# path read is the foreground process. An empty or unreadable cwd preserves +# the agent. Path identity uses the physical directory. +require_relaunch_occupied_worktree() { + local seen seen_real wt_real seen_ino wt_ino + wt_real=$(CDPATH='' cd -- "$WT" 2>/dev/null && pwd -P) \ + || die "task $ID's recorded worktree $WT cannot be resolved" + seen=$(fm_backend_current_path "$BACKEND" "$T" 2>/dev/null) || seen= + seen=$(printf '%s' "$seen" | tr -d '\r') + if [ -z "$seen" ]; then + die "task $ID's live working directory cannot be verified; refusing to stop the agent without proof it occupies $WT" + fi + seen_real=$(CDPATH='' cd -- "$seen" 2>/dev/null && pwd -P) || seen_real= + if [ -n "$seen_real" ] && [ "$seen_real" = "$wt_real" ]; then + return 0 + fi + if [ "$(uname -s)" = Darwin ]; then + seen_ino=$(stat -f '%d:%i' "$seen" 2>/dev/null || true) + wt_ino=$(stat -f '%d:%i' "$wt_real" 2>/dev/null || true) + else + seen_ino=$(stat -c '%d:%i' "$seen" 2>/dev/null || true) + wt_ino=$(stat -c '%d:%i' "$wt_real" 2>/dev/null || true) + fi + if [ -n "$seen_ino" ] && [ "$seen_ino" = "$wt_ino" ]; then + return 0 + fi + die "task $ID's live shell is in ${seen_real:-$seen}, not its recorded worktree $WT; refusing to stop the agent" +} + # safe_checkpoint: prove, before anything is stopped, that the work a relaunch # must preserve is actually there and recoverable afterwards. Fills # CHECKPOINT_LINES with the journal lines describing what it proved, and @@ -814,6 +848,7 @@ do_relaunch() { note_line="note=none" fi safe_checkpoint + require_relaunch_occupied_worktree cp -p "$META" "$META_PRIOR" || die "could not preserve task $ID's durable record before relaunching" RELAUNCH_ACTIVE=1 journal_write checkpoint "${CHECKPOINT_LINES[@]}" "$note_line" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 1c27df82883..8c18ecbd97d 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -39,9 +39,13 @@ # model, and effort may change, which is what makes a harness switch one # ordinary relaunch. It refuses unless the recorded endpoint is positively # agent-free on a backend with a recovery-grade agent-state classifier (tmux -# or herdr), refuses unless the endpoint's shell is sitting in the recorded -# worktree, and clears the previous harness's per-task wiring before arming -# the new incarnation. +# or herdr). The control plane verifies the live occupied directory before +# stopping the previous agent; after stop, the idle shell may have returned +# to the backend's launch directory (Herdr's frozen pane.cwd), so this +# script cds into the recorded worktree rather than trusting that launch +# path, and refuses if the pane still cannot enter the copy holding the +# work. It clears the previous harness's per-task wiring before arming the +# new incarnation. # --harness is the explicit per-spawn harness/profile adapter. The old # positional harness arg still works for back-compat. # --model and --effort are concrete profile @@ -175,6 +179,10 @@ # Ship/scout spawns refuse to launch unless the resolved task path is a real # git worktree root distinct from both the spawning project and its repository's # primary checkout, including when the spawning project is a linked worktree. +# A project path of `.` or `..` is resolved to that repository's primary +# working tree (fm-tangle-lib.sh) before isolation comparisons, so a linked +# firstmate worktree is not treated as the primary and a genuine treehouse +# copy is not refused. # On the backends that discover that path by reading the task pane's own cwd, # the same isolation test screens every read: a pane still showing the project # or the repository primary while `treehouse get` prepares the slot is waited @@ -423,6 +431,8 @@ fm_backlog_directory_present "$STATE" "state directory" || { . "$SCRIPT_DIR/fm-pr-lib.sh" # shellcheck source=bin/fm-dod-lib.sh . "$SCRIPT_DIR/fm-dod-lib.sh" +# shellcheck source=bin/fm-tangle-lib.sh +. "$SCRIPT_DIR/fm-tangle-lib.sh" # shellcheck source=bin/fm-trace-context-lib.sh . "$SCRIPT_DIR/fm-trace-context-lib.sh" # shellcheck source=bin/fm-remote-readiness-lib.sh @@ -1963,9 +1973,20 @@ resolved_existing_dir() { } resolve_project_dir_arg() { - local path=$1 + local path=$1 abs primary case "$path" in - projects/*) printf '%s/%s\n' "$PROJECTS" "${path#projects/}" ;; + projects/*) printf '%s/%s\n' "$PROJECTS" "${path#projects/}"; return 0 ;; + .|..) + abs=$(CDPATH='' cd -- "$path" 2>/dev/null && pwd -P) || { + printf '%s\n' "$path" + return 0 + } + if primary=$(fm_git_primary_workdir "$abs"); then + printf '%s\n' "$primary" + return 0 + fi + printf '%s\n' "$abs" + ;; *) printf '%s\n' "$path" ;; esac } @@ -2142,7 +2163,7 @@ if [ "$KIND" = secondmate ]; then BRIEF="$DATA/$ID/brief.md" fi else - PROJ_ABS="$(cd "$(resolve_project_dir_arg "$PROJ")" && pwd)" + PROJ_ABS="$(CDPATH='' cd -- "$(resolve_project_dir_arg "$PROJ")" && pwd -P)" WT="" BRIEF="$DATA/$ID/brief.md" fi @@ -2993,17 +3014,21 @@ rovo_endpoint_cleanup() { } if [ "$RELAUNCH" -eq 1 ]; then - # No worktree is acquired: the recorded one is reused as-is. What must be - # proven instead is that the adopted endpoint's shell is actually sitting in - # that worktree, so the replacement agent starts where the work is rather - # than wherever the pane happened to drift. + # No worktree is acquired: the recorded one is reused as-is. After the + # previous agent stops, the idle shell may sit in the backend's launch + # directory (Herdr pane.cwd is frozen there). Enter the recorded copy + # rather than trusting that launch path. relaunch_wt_real=$(real_path_or_raw "$WT") - relaunch_seen= - for _ in $(seq 1 10); do - relaunch_seen=$(spawn_current_path "$WT_TARGET" || true) - [ -z "$relaunch_seen" ] || [ "$(real_path_or_raw "$relaunch_seen")" != "$relaunch_wt_real" ] || break - sleep 0.5 - done + relaunch_seen=$(spawn_current_path "$WT_TARGET" || true) + if [ -z "$relaunch_seen" ] || [ "$(real_path_or_raw "$relaunch_seen")" != "$relaunch_wt_real" ]; then + spawn_send_text_line "$WT_TARGET" "cd $(shell_quote "$relaunch_wt_real")" + relaunch_seen= + for _ in $(seq 1 10); do + relaunch_seen=$(spawn_current_path "$WT_TARGET" || true) + [ -z "$relaunch_seen" ] || [ "$(real_path_or_raw "$relaunch_seen")" != "$relaunch_wt_real" ] || break + sleep 0.5 + done + fi if [ -z "$relaunch_seen" ] || [ "$(real_path_or_raw "$relaunch_seen")" != "$relaunch_wt_real" ]; then echo "error: task $ID's endpoint is in '${relaunch_seen:-unknown}', not its recorded worktree '$WT'; refusing to relaunch an agent outside the copy holding its work" >&2 exit 1 diff --git a/bin/fm-tangle-lib.sh b/bin/fm-tangle-lib.sh index a8554fbd60a..13b9dc4ab6d 100644 --- a/bin/fm-tangle-lib.sh +++ b/bin/fm-tangle-lib.sh @@ -35,6 +35,23 @@ fm_default_branch() { return 1 } +# The primary working tree of the repository that belongs to. +# For a linked worktree this is the directory whose git dir is the common dir, +# not itself. Equality of pwd and git-toplevel cannot find that primary: +# both names are the current worktree root in the primary and in a linked copy. +# Echoes an absolute physical path, or returns 1 if it cannot be resolved. +fm_git_primary_workdir() { + local abs common parent parent_git + abs=$(CDPATH='' cd -- "$1" 2>/dev/null && pwd -P) || return 1 + common=$(git -C "$abs" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1 + common=$(CDPATH='' cd -- "$common" 2>/dev/null && pwd -P) || return 1 + parent=$(dirname "$common") + parent_git=$(git -C "$parent" rev-parse --path-format=absolute --absolute-git-dir 2>/dev/null) || return 1 + parent_git=$(CDPATH='' cd -- "$parent_git" 2>/dev/null && pwd -P) || return 1 + [ "$parent_git" = "$common" ] || return 1 + printf '%s\n' "$parent" +} + # If the git checkout at is tangled - on a NAMED branch that is not its # default branch - echo the offending branch name and return 0. For every healthy # state (not a git work tree, detached HEAD, or already on the default branch) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index fd2db419806..a138024909f 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -74,14 +74,20 @@ # name a slot a DIFFERENT live task now holds. Cleanup kills every process under # that path and hard-resets it before returning it, so releasing a slot that is # not genuinely this task's destroys another worker's live work. Before the first -# cleanup step, teardown verifies record exclusivity: no OTHER task record in -# this home or any locally registered Firstmate home may name the same live path -# in its worktree= or home=. One live path with two task records is the reuse -# collision itself, whichever record is stale. The recorded endpoint's exact -# task identity and the record's spawn incarnation are validated separately -# before cleanup. Its current working directory is only incidental process -# state: the same worker remains the owner after changing directory, so cwd can -# never veto teardown of that exact recorded endpoint. +# cleanup step, teardown verifies copy exclusivity against every other live +# claim: no OTHER task record in this home or any locally registered Firstmate +# home may name the same copy in its worktree= or home=. Identity is the +# complete copy, not a single path spelling: physical directory, device:inode, +# and git worktree git-dir. A git-dir path and a working-tree path for the same +# linked worktree are one claim. The check runs for any ship or scout worktree +# being removed, not only a recognized pool slot, so a shared ordinary +# worktree is refused before any process is signalled. One live copy with two +# task records is the reuse collision itself, whichever record is stale. The +# recorded endpoint's exact task identity and the record's spawn incarnation +# are validated separately before cleanup. Its current working directory is +# only incidental process state: the same worker remains the owner after +# changing directory, so cwd can never veto teardown of that exact recorded +# endpoint. # The scan and destructive return hold a project-identity lock in the local root # Firstmate home's state directory, as resolved by bin/fm-wake-lib.sh's # fm_firstmate_root_home; a home seeded from another machine is its own local @@ -202,9 +208,14 @@ # hours with no live task meta to attribute them to once teardown had # already removed it). reap_task_worktree_processes finds every process # whose CURRENT WORKING DIRECTORY is this task's own worktree or tasktmp -# root via `lsof -a -d cwd` (cheap: bounded by process count, not by -# walking the worktree's file tree) and sends TERM, then KILL after a short -# grace period to any survivor whose process identity still matches. Both +# root. The scan is bounded by process count, never by walking the +# worktree's file tree, and never selects by process name. A bounded +# Linux-compatible `/proc//cwd` read (or FM_PROC_ROOT_OVERRIDE in +# tests) is preferred because it is a kernel fact and needs no optional +# tool; `lsof -a -d cwd` is the portable fallback on hosts such as macOS. +# This identifies the processes on the first cleanup so a leaked descendant +# does not force a failed return before it can be reaped. A host with +# neither cwd source uses the backend process-group fallback. Both # roots are unique per task and never # shared, so this can never reach another task's or the primary's # processes. Idempotent: nothing left to find is a silent no-op. @@ -1479,6 +1490,44 @@ canonical_existing_dir() { ( cd "$target" && pwd -P ) } +dir_inode_id() { # + if [ "$(uname -s)" = Darwin ]; then + stat -f '%d:%i' "$1" 2>/dev/null + else + stat -c '%d:%i' "$1" 2>/dev/null + fi +} + +# Absolute git dir of a working tree or of a git-dir path, physically resolved. +dir_git_id() { # + local g + if g=$(git -C "$1" rev-parse --path-format=absolute --absolute-git-dir 2>/dev/null); then + CDPATH='' cd -- "$g" 2>/dev/null && pwd -P + return 0 + fi + if g=$(git --git-dir="$1" rev-parse --path-format=absolute --absolute-git-dir 2>/dev/null); then + CDPATH='' cd -- "$g" 2>/dev/null && pwd -P + return 0 + fi + return 1 +} + +# True when and name the same live copy: physical path, inode, +# or git worktree identity. A working tree and its git-dir path match. +worktree_copies_match() { # + local a=$1 b=$2 a_path b_path a_ino b_ino a_git b_git + a_path=$(canonical_existing_dir "$a") || return 1 + b_path=$(canonical_existing_dir "$b") || return 1 + [ "$a_path" = "$b_path" ] && return 0 + a_ino=$(dir_inode_id "$a_path") || a_ino= + b_ino=$(dir_inode_id "$b_path") || b_ino= + [ -n "$a_ino" ] && [ "$a_ino" = "$b_ino" ] && return 0 + a_git=$(dir_git_id "$a_path") || a_git= + b_git=$(dir_git_id "$b_path") || b_git= + [ -n "$a_git" ] && [ "$a_git" = "$b_git" ] && return 0 + return 1 +} + retry_wait_secs_is_valid() { [[ "$1" =~ ^([0-9]+([.][0-9]*)?|[.][0-9]+)$ ]] } @@ -1816,15 +1865,74 @@ conclude_task_no_mistakes_run() { # return 1 } +# True when a Linux-compatible /proc cwd scan can identify processes without +# lsof. FM_PROC_ROOT_OVERRIDE is the test seam; production uses /proc. +proc_cwd_scan_available() { + local proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} here probe seen seen_real + [ -d "$proc_root" ] || return 1 + here=$(pwd -P 2>/dev/null) || return 1 + probe="$proc_root/$$/cwd" + [ -L "$probe" ] || return 1 + seen=$(readlink "$probe" 2>/dev/null) || return 1 + seen_real=$(CDPATH='' cd -- "$seen" 2>/dev/null && pwd -P) || return 1 + [ "$seen_real" = "$here" ] +} + +# The shells that invoked this teardown are the cleanup control plane, not +# leftovers. Resolve only their exact pid chain; descendants remain eligible. +teardown_ancestor_pids() { + local current=$$ parent hops=0 + while [ "$hops" -lt 64 ]; do + parent=$(ps -o ppid= -p "$current" 2>/dev/null) || return 0 + parent=$(printf '%s' "$parent" | tr -d '[:space:]') + case "$parent" in ''|*[!0-9]*|0|1) return 0 ;; esac + printf '%s\n' "$parent" + current=$parent + hops=$((hops + 1)) + done +} + +pid_list_has() { # + printf '%s\n' "$1" | grep -Fxq "$2" +} + +# Bounded /proc//cwd scan: process count, never a file-tree walk, never +# a process-name match, never another home's processes. Only pids whose cwd +# is exactly or under it. Never this teardown or its invoking shells. +pids_with_cwd_under_proc() { # + local dir=$1 ancestors=$2 proc_root pid pid_dir cwd cwd_real + proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} + [ -n "$dir" ] && [ -d "$dir" ] || return 0 + dir=$(cd "$dir" && pwd -P) || return 1 + for pid_dir in "$proc_root"/[0-9]*; do + [ -e "$pid_dir" ] || continue + pid=${pid_dir##*/} + case "$pid" in ''|*[!0-9]*) continue ;; esac + [ "$pid" != "$$" ] || continue + pid_list_has "$ancestors" "$pid" && continue + cwd=$(readlink "$pid_dir/cwd" 2>/dev/null) || continue + cwd_real=$(CDPATH='' cd -- "$cwd" 2>/dev/null && pwd -P) || cwd_real=$cwd + case "$cwd_real" in + "$dir"|"$dir"/*) printf '%s\n' "$pid" ;; + esac + done +} + # Fix 2 (see script header): pids of every process whose CURRENT WORKING -# DIRECTORY is exactly $1 or under it, from one bounded system-wide `lsof -a -# -d cwd` scan (never the recursive +D file-tree walk, which lsof itself -# documents as slow). Never $$ (this script's own pid). Empty output when -# nothing matches; failure means the scan could not establish a safe result. +# DIRECTORY is exactly $1 or under it, from one bounded scan (never a +# recursive file-tree walk). Prefer Linux-compatible /proc//cwd; use lsof +# as the portable fallback. Never $$. Empty output when nothing matches; failure means the scan could +# not establish a safe result. pids_with_cwd_under() { # - local dir=$1 out pid path line + local dir=$1 out pid path line ancestors [ -n "$dir" ] && [ -d "$dir" ] || return 0 dir=$(cd "$dir" && pwd -P) || return 1 + ancestors=$(teardown_ancestor_pids) + if proc_cwd_scan_available; then + pids_with_cwd_under_proc "$dir" "$ancestors" + return 0 + fi + command -v lsof >/dev/null 2>&1 || return 1 out=$(lsof -a -d cwd -Fpn 2>/dev/null) || return 1 [ -n "$out" ] || return 0 pid= @@ -1840,7 +1948,10 @@ pids_with_cwd_under() { # path=${line#n} case "$path" in "$dir"|"$dir"/*) - [ -n "$pid" ] && [ "$pid" != "$$" ] && printf '%s\n' "$pid" + if [ -n "$pid" ] && [ "$pid" != "$$" ] \ + && ! pid_list_has "$ancestors" "$pid"; then + printf '%s\n' "$pid" + fi ;; esac ;; @@ -1946,13 +2057,25 @@ reap_task_backend_process_group() { #