Skip to content
Closed
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
21 changes: 21 additions & 0 deletions scripts/lib/actas-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")"
Expand Down
21 changes: 21 additions & 0 deletions scripts/spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -433,6 +438,20 @@ esac
# 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'
# 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"
Expand Down Expand Up @@ -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"
Expand Down
39 changes: 39 additions & 0 deletions tests/test_spawn.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down