Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion scripts/despawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id> for herdr, and pid:<N> 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
;;
Expand Down Expand Up @@ -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"
Expand All @@ -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"
16 changes: 16 additions & 0 deletions scripts/spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -426,13 +426,29 @@ 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"
# Mark the launched session as spawn-born (#339): the CLI inherits this, so the
# actas flow knows the session is already named <team>-<agent> (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"
Expand Down
81 changes: 81 additions & 0 deletions tests/test_despawn.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/test_spawn.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'* ]]
}

Expand Down
Loading