diff --git a/scripts/lib/actas-lock.sh b/scripts/lib/actas-lock.sh index 7b00f584..b82cd799 100644 --- a/scripts/lib/actas-lock.sh +++ b/scripts/lib/actas-lock.sh @@ -83,6 +83,27 @@ agmsg_spawn_path() { printf '%s/spawn.%s__%s' "$(_actas_lock_dir)" "$t" "$a" } +# Boot-process presence path for a spawned (team, agent). The boot script +# generated by `spawn.sh` writes its own pid here right before exec'ing the +# role's CLI, and removes it right after that CLI exits. Present iff a +# launcher-spawned CLI process for that role is currently running -- +# independent of the ready sentinel above, which only +# appears once the CLI/model reaches its own watcher-registration step. A +# type whose actas flow is model-driven (e.g. grok-build's watcher start, +# see docs/adr/0003) can get permanently stuck before that step -- most +# visibly when the underlying model call itself fails (a rate/usage-limit +# dialog the model never resolves) -- leaving no watcher and no ready +# sentinel even though the CLI process is still alive on-screen. This file +# lets an external liveness check (or a future spawn/status command) tell +# "process launched and still running, registration incomplete" apart from +# "nothing was ever launched, or it has exited." Same encoding as the lock +# path. +agmsg_boot_pid_path() { + local team="$1" agent="$2" + local t a; t="$(_actas_lock_encode "$team")"; a="$(_actas_lock_encode "$agent")" + printf '%s/boot-pid.%s__%s' "$(_actas_lock_dir)" "$t" "$a" +} + # Read the owner session_id of a lock file. Empty if no lock or unreadable. actas_lock_owner() { local lock; lock="$(actas_lock_path "$1" "$2")" diff --git a/scripts/spawn.sh b/scripts/spawn.sh index 3a306d1b..40e24b7a 100755 --- a/scripts/spawn.sh +++ b/scripts/spawn.sh @@ -411,6 +411,11 @@ if [ "$_msys_cmd_prefix" = "/" ]; then MSYS_GUARD="MSYS2_ARG_CONV_EXCL=/${_msys_cmd_name} " fi +# Presence sentinel for this launch (see agmsg_boot_pid_path, lib/actas-lock.sh): +# the boot script records its own pid here before exec'ing the CLI and clears +# it right after, independent of whatever the CLI/model does or doesn't reach. +BOOT_PID_PATH="$(agmsg_boot_pid_path "$TEAM" "$NAME")" + BOOT_DIR="${TMPDIR:-/tmp}/agmsg-spawn" mkdir -p "$BOOT_DIR" 2>/dev/null || true # Best-effort GC of boot scripts left behind by spawns whose window was closed @@ -433,6 +438,20 @@ 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' + # Record boot-process presence before the CLI runs (see agmsg_boot_pid_path): + # $$ here is the boot script's own shell pid, not spawn.sh's -- it stays + # alive for exactly as long as the CLI below runs (foreground, not exec'd), + # so a liveness check on this pid tracks the CLI's lifetime even if the + # CLI/model never reaches its own watcher-registration step. A consumer + # always validates via kill(pid, 0) (same contract as every other sentinel + # here), so a stale file is harmless -- but the trap below still clears it + # on the common abnormal-exit paths (closed window/tab -> HUP, killed pane + # -> TERM, Ctrl-C -> INT) so it doesn't needlessly linger; only a SIGKILL + # (untrappable) leaves it behind for the next liveness check to invalidate. + printf 'mkdir -p %q 2>/dev/null || true\n' "$(_actas_lock_dir)" + printf 'AGMSG_BOOT_PID_PATH=%q\n' "$BOOT_PID_PATH" + printf 'echo $$ > "$AGMSG_BOOT_PID_PATH" 2>/dev/null || true\n' + printf 'trap '\''rm -f "$AGMSG_BOOT_PID_PATH" 2>/dev/null'\'' EXIT HUP TERM INT\n' # 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" @@ -477,6 +496,8 @@ esac agmsg_role_cli_args "$AGENT_TYPE" "$SESSION_NAME" "$ACTAS_PROMPT" printf '\n' fi + echo 'trap - EXIT HUP TERM INT' # CLI returned normally -- the explicit rm below covers it, not the trap + echo 'rm -f "$AGMSG_BOOT_PID_PATH" 2>/dev/null' # presence over echo 'rm -f "$0" 2>/dev/null' # self-clean once the agent exits echo 'exec "${SHELL:-/bin/bash}" -i' } > "$BOOT" diff --git a/tests/test_spawn.bats b/tests/test_spawn.bats index 681e6948..2f43bb94 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -183,6 +183,45 @@ teardown() { [[ "$output" == *"export AGMSG_SPAWNED=1"* ]] } +@test "spawn: boot script records and clears a boot-pid presence sentinel" { + # The boot script writes its own pid to agmsg_boot_pid_path before running + # the CLI, and removes it right after -- a liveness signal independent of + # the ready sentinel, which only appears once the CLI/model reaches its own + # watcher-registration step (some types, e.g. grok-build, never get there + # when the underlying model call itself fails, such as an unresolved + # rate-limit dialog). + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + run bash "$SCRIPTS/spawn.sh" claude-code alice --project "$PROJ" --no-wait + [ "$status" -eq 0 ] + + export SKILL_DIR="$TEST_SKILL_DIR" + source "$SCRIPTS/lib/actas-lock.sh" + local pid_path; pid_path="$(agmsg_boot_pid_path myteam alice)" + + boot="$(cat "$CAPTURE")"; run cat "$boot" + # The literal path is assigned to a boot-local var once, then referenced by + # name everywhere else (write, trap, clear) rather than re-embedded. + [[ "$output" == *"AGMSG_BOOT_PID_PATH=$pid_path"* ]] + [[ "$output" == *'echo $$ > "$AGMSG_BOOT_PID_PATH"'* ]] + [[ "$output" == *'trap '"'"'rm -f "$AGMSG_BOOT_PID_PATH"'* ]] + [[ "$output" == *"EXIT HUP TERM INT"* ]] + [[ "$output" == *'rm -f "$AGMSG_BOOT_PID_PATH"'* ]] + # Written before the CLI line, cleared after it -- not the other way round. + # (The CLI invocation is asserted-between, not line-anchored, since it may be + # prefixed by MSYS2_ARG_CONV_EXCL=... even on macOS/Linux -- inert there, but + # it means the line doesn't start with the bare cli name.) + local write_line clear_line between + write_line="$(grep -n "AGMSG_BOOT_PID_PATH=" "$boot" | head -1 | cut -d: -f1)" + clear_line="$(grep -n "^rm -f \"\$AGMSG_BOOT_PID_PATH\"" "$boot" | head -1 | cut -d: -f1)" + [ -n "$write_line" ] + [ -n "$clear_line" ] + [ "$write_line" -lt "$clear_line" ] + between="$(sed -n "${write_line},${clear_line}p" "$boot")" + [[ "$between" == *"claude"* ]] + [[ "$between" == *"actas"* ]] + [[ "$between" == *"alice"* ]] +} + @test "spawn: a type without name_arg emits no name flag (#339)" { # gemini's manifest has no name_arg=, so the boot script must not name the # session -- no bare `-n` token, unchanged from pre-#339 behavior.