From 9ed7882dc908553490b6dfd2cd8539db249b72b0 Mon Sep 17 00:00:00 2001 From: Masa <98894508+Masashi-Ono0611@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:43:46 +0900 Subject: [PATCH] feat(spawn): boot-process presence sentinel independent of the ready sentinel (#1) The boot script spawn.sh generates now writes its own pid to run/boot-pid.__ right before exec'ing the role's CLI, and clears it right after -- on normal return, and via a trap on HUP/TERM/INT for abrupt termination (SIGKILL still leaves it for the next kill(pid,0) liveness check to invalidate, same contract as every other sentinel in this codebase). This is a liveness signal independent of the existing ready.__ sentinel, which only appears once the CLI/model reaches its own watcher-registration step. Some agent types (grok-build) start their watcher only when the model follows the actas template's instructions (ADR 0003); when the very first model turn itself fails -- e.g. an unresolved rate/usage-limit dialog the CLI presents instead of continuing -- that step is never reached, and an external liveness check has no way to tell "launched and still running, registration incomplete" apart from "nothing was ever launched, or it already exited." Reviewed with Codex (self multi-model review, bounded codex exec) -- 1 Warning addressed (HUP/TERM/INT trap), 2 Suggestions reviewed and left as consistent with existing repo conventions. 65/65 spawn tests pass plus a new test for the sentinel; no regressions in resurrect-panes/actas-lock suites. CI green on ubuntu/macos/windows. --- scripts/lib/actas-lock.sh | 21 +++++++++++++++++++++ scripts/spawn.sh | 21 +++++++++++++++++++++ tests/test_spawn.bats | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) 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.