diff --git a/scripts/despawn.sh b/scripts/despawn.sh index 8f5fc4ed..31f3bf05 100755 --- a/scripts/despawn.sh +++ b/scripts/despawn.sh @@ -50,13 +50,36 @@ case "$TIMEOUT" in ''|*[!0-9]*) die "--timeout must be a whole number of seconds SPAWN_REC="$(agmsg_spawn_path "$TEAM" "$NAME")" -# Kill the recorded tmux target. ids are self-describing: %N pane, @N window. +# Kill the recorded placement. ids are self-describing: %N/@N for tmux, +# herdr: for herdr, and pid: for an OS-terminal boot process. +kill_pid_tree() { + local pid="$1" child children + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + # pgrep -P is available on both macOS and Linux. Keep a ps fallback for + # minimal environments where procps is present without pgrep. + if command -v pgrep >/dev/null 2>&1; then + children="$(pgrep -P "$pid" 2>/dev/null || true)" + else + children="$(ps -o pid= --ppid "$pid" 2>/dev/null || true)" + fi + # Stop descendants before their boot-shell parent. This works for terminal + # templates that do not make the boot script a process-group leader. + while IFS= read -r child; do + child="${child//[[:space:]]/}" + [ -n "$child" ] && kill_pid_tree "$child" + done <<< "$children" + kill "$pid" 2>/dev/null || true +} + kill_recorded_placement() { [ -f "$SPAWN_REC" ] || return 1 local id _proj _type IFS=$'\t' read -r id _proj _type < "$SPAWN_REC" [ -n "$id" ] || return 1 case "$id" in + pid:*) + kill_pid_tree "${id#pid:}" || true + ;; herdr:*) command -v herdr >/dev/null 2>&1 && herdr pane close "${id#herdr:}" 2>/dev/null || true ;; @@ -91,6 +114,23 @@ fi state="$(actas_lock_state "$TEAM" "$NAME" "" 2>/dev/null || echo free)" case "$state" in free) + if [ -f "$SPAWN_REC" ]; then + IFS=$'\t' read -r _id _proj _type < "$SPAWN_REC" + case "${_id:-}" in + pid:*) + # OS-terminal members (notably codex) may have no watcher/actas lock. + # The boot PID is still an owned placement, so graceful despawn can + # terminate it and consistently remove the pre-joined registration. + kill_recorded_placement >/dev/null + if [ -n "${_proj:-}" ] && [ -n "${_type:-}" ]; then + "$SCRIPT_DIR/reset.sh" "$_proj" "$_type" "$NAME" >/dev/null 2>&1 || true + fi + rm -f "$SPAWN_REC" 2>/dev/null || true + echo "status=ok name=$NAME team=$TEAM note=os-terminal" + exit 0 + ;; + esac + fi echo "despawn: '$NAME' holds no live actas lock — nothing to confirm a teardown against (a codex member has no watcher; a tmux member may already be gone). If a window remains, use --force." >&2 rm -f "$SPAWN_REC" 2>/dev/null || true echo "status=ok name=$NAME team=$TEAM note=no-live-lock" @@ -113,5 +153,19 @@ while true; do waited=$((waited + 1)) done +# A graceful OS-terminal watcher can release its role but cannot portably close +# the containing terminal. Once the lock is gone, use the boot PID placement to +# finish teardown and make registration cleanup idempotent from the leader side. +if [ -f "$SPAWN_REC" ]; then + IFS=$'\t' read -r _id _proj _type < "$SPAWN_REC" + case "${_id:-}" in + pid:*) + kill_recorded_placement >/dev/null + if [ -n "${_proj:-}" ] && [ -n "${_type:-}" ]; then + "$SCRIPT_DIR/reset.sh" "$_proj" "$_type" "$NAME" >/dev/null 2>&1 || true + fi + ;; + esac +fi rm -f "$SPAWN_REC" 2>/dev/null || true echo "status=ok name=$NAME team=$TEAM after=${waited}s" diff --git a/scripts/spawn.sh b/scripts/spawn.sh index 976edbd9..8f6af611 100755 --- a/scripts/spawn.sh +++ b/scripts/spawn.sh @@ -426,6 +426,16 @@ BOOT="$(mktemp "$BOOT_DIR/boot-XXXXXX")" case "$(uname -s)" in Darwin) mv "$BOOT" "$BOOT.command"; BOOT="$BOOT.command" ;; esac + +# Tmux/herdr have stable native placement identifiers that their launchers +# record below. An OS terminal has no portable window handle, so let the boot +# script record its own PID instead; despawn can then terminate that process +# tree and still has the project/type metadata needed to drop registration. +RECORD_BOOT_PID=1 +if [ -n "${TMUX:-}" ] \ + || { [ "${HERDR_ENV:-}" = "1" ] && [ -n "${HERDR_PANE_ID:-}" ] && command -v herdr >/dev/null 2>&1; }; then + RECORD_BOOT_PID=0 +fi { echo '#!/usr/bin/env bash' printf 'cd %q || exit 1\n' "$PROJECT" @@ -433,6 +443,12 @@ esac # actas flow knows the session is already named - (name_arg) and # suppresses the "rename this session" tip meant for hand-started sessions. echo 'export AGMSG_SPAWNED=1' + if [ "$RECORD_BOOT_PID" = "1" ]; then + _spawn_rec="$(agmsg_spawn_path "$TEAM" "$NAME")" + printf 'mkdir -p %q\n' "$(dirname "$_spawn_rec")" + printf 'printf %q "pid:$$" %q %q > %q\n' \ + '%s\t%s\t%s\n' "$PROJECT" "$AGENT_TYPE" "$_spawn_rec" + fi # Drop inherited same-type session-identity vars before exec'ing the CLI (#294). if [ -n "$SPAWN_UNSET_VARS" ]; then printf 'unset %s\n' "$SPAWN_UNSET_VARS" diff --git a/tests/test_despawn.bats b/tests/test_despawn.bats index 5048cb23..88938d35 100644 --- a/tests/test_despawn.bats +++ b/tests/test_despawn.bats @@ -65,6 +65,32 @@ _read_at_for_body() { "SELECT read_at FROM messages WHERE body='$1' ORDER BY id DESC LIMIT 1;" ) } +@test "despawn: graceful OS-terminal member is terminated after releasing its live lock" { + bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null + bash "$SCRIPTS/join.sh" team leader claude-code "$PROJ" >/dev/null + setup_live_owner "$RUN" sess-m + printf 'sess-m\n' > "$RUN/actas.team__alice.session" + printf 'pid:999999\t%s\tclaude-code\n' "$PROJ" > "$RUN/spawn.team__alice" + + export KILL_LOG="$TEST_SKILL_DIR/kill.log" + kill() { printf '%s\n' "$*" >> "$KILL_LOG"; } + export -f kill + export LOCK_TO_RELEASE="$RUN/actas.team__alice.session" + local stub_bin="$TEST_SKILL_DIR/stub-bin-graceful" + mkdir -p "$stub_bin" + cat > "$stub_bin/sleep" <<'STUB' +#!/usr/bin/env bash +rm -f "$LOCK_TO_RELEASE" +STUB + chmod +x "$stub_bin/sleep" + + run env PATH="$stub_bin:$PATH" bash "$SCRIPTS/despawn.sh" team leader alice --timeout 2 + [ "$status" -eq 0 ] + [[ "$output" == *"status=ok"* ]] + grep -q -- '999999' "$KILL_LOG" + [ ! -f "$RUN/spawn.team__alice" ] +} + @test "despawn: graceful — ctrl:despawn control row is marked read (does not linger as unread)" { bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null bash "$SCRIPTS/join.sh" team leader claude-code "$PROJ" >/dev/null @@ -103,6 +129,46 @@ _read_at_for_body() { [[ "$output" != *alice* ]] # registration dropped } +@test "despawn --force: kills recorded OS-terminal PID and drops registration" { + bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null + printf 'pid:999999\t%s\tclaude-code\n' "$PROJ" > "$RUN/spawn.team__alice" + export KILL_LOG="$TEST_SKILL_DIR/kill.log" + kill() { printf '%s\n' "$*" >> "$KILL_LOG"; } + export -f kill + + run bash "$SCRIPTS/despawn.sh" team leader alice --force + [ "$status" -eq 0 ] + [[ "$output" == *"status=forced"* ]] + grep -q -- '999999' "$KILL_LOG" + [ ! -f "$RUN/spawn.team__alice" ] + run bash "$SCRIPTS/identities.sh" "$PROJ" claude-code + [[ "$output" != *alice* ]] +} + +@test "despawn --force: terminates OS-terminal descendants before the boot PID" { + bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null + printf 'pid:999999\t%s\tclaude-code\n' "$PROJ" > "$RUN/spawn.team__alice" + export KILL_LOG="$TEST_SKILL_DIR/kill.log" + kill() { printf '%s\n' "$*" >> "$KILL_LOG"; } + export -f kill + local stub_bin="$TEST_SKILL_DIR/stub-bin-pgrep" + mkdir -p "$stub_bin" + cat > "$stub_bin/pgrep" <<'STUB' +#!/usr/bin/env bash +[ "${2:-}" = "999999" ] && printf '999998\n' +STUB + chmod +x "$stub_bin/pgrep" + + run env PATH="$stub_bin:$PATH" bash "$SCRIPTS/despawn.sh" team leader alice --force + [ "$status" -eq 0 ] + local calls=() call + while IFS= read -r call; do + calls+=("$call") + done < "$KILL_LOG" + [ "${calls[0]}" = "999998" ] + [ "${calls[1]}" = "999999" ] +} + @test "despawn --force: errors when there is no placement record" { bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null run bash "$SCRIPTS/despawn.sh" team leader alice --force @@ -148,6 +214,21 @@ _read_at_for_body() { kill "$wpid" 2>/dev/null || true; wait "$wpid" 2>/dev/null || true } +@test "despawn: graceful OS-terminal member without a live lock is terminated and de-registered" { + bash "$SCRIPTS/join.sh" team alice codex "$PROJ" >/dev/null + printf 'pid:999999\t%s\tcodex\n' "$PROJ" > "$RUN/spawn.team__alice" + export KILL_LOG="$TEST_SKILL_DIR/kill.log" + kill() { printf '%s\n' "$*" >> "$KILL_LOG"; } + export -f kill + + run bash "$SCRIPTS/despawn.sh" team leader alice + [ "$status" -eq 0 ] + [[ "$output" == *"status=ok"* ]] + grep -q -- '999999' "$KILL_LOG" + run bash "$SCRIPTS/identities.sh" "$PROJ" codex + [[ "$output" != *alice* ]] +} + @test "despawn: graceful no-op when the member holds no live lock (e.g. codex)" { bash "$SCRIPTS/join.sh" team alice codex "$PROJ" >/dev/null run bash "$SCRIPTS/despawn.sh" team leader alice diff --git a/tests/test_spawn.bats b/tests/test_spawn.bats index 48565edb..beb3be67 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -139,6 +139,25 @@ teardown() { # --- happy path / launch command --- +@test "spawn: OS-terminal boot records its PID for despawn" { + cat > "$STUB_BIN/run-boot.sh" <<'STUB' +#!/usr/bin/env bash +printf 'exit\n' | "$1" +STUB + chmod +x "$STUB_BIN/run-boot.sh" + export AGMSG_TERMINAL="$STUB_BIN/run-boot.sh {cmd}" + + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + run bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + + local record="$TEST_SKILL_DIR/run/spawn.myteam__alice" + [ -f "$record" ] + run cat "$record" + [ "$status" -eq 0 ] + [[ "$output" =~ ^pid:[0-9]+$'\t'"$PROJ"$'\t'claude-code$ ]] +} + @test "spawn: pre-joins the name and launches the CLI with the actas prompt" { bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" run bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait @@ -867,6 +886,8 @@ EOF [[ "$output" == *"actas"* ]] [[ "$output" == *"alice"* ]] # No task appended → no newline-join → boot prompt unchanged. + run grep 'actas' "$boot" + [ "$status" -eq 0 ] [[ "$output" != *'\n'* ]] }