diff --git a/README.md b/README.md index ddc01103..a2b07801 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ Where `actas` switches *this* session to a different role, `spawn` brings up a * Pass `--boot-prompt ` to hand the new agent an initial task: the boot prompt becomes the actas slash command followed (newline-separated) by your text, so the agent claims its identity **and** acts on the task in the same first turn. This is the only way to give a one-shot goal to a **codex** peer, which has no Monitor and so never notices a message you `send` after it goes idle. -By default `spawn` **blocks until the new agent is actually listening** — its watcher attaches and touches a readiness sentinel — then prints `status=ready`, so you can send work the moment `spawn` returns without losing it to the agent's cold start. Use `--no-wait` for fire-and-forget, or `--ready-timeout ` to bound the wait (default 90; on timeout it prints `status=timeout` and exits 3 so a caller can re-spawn). Codex skips the wait (it has no Monitor). +When a driver exposes a readiness handshake, `spawn` **blocks until the new agent is actually ready** — either its watcher attaches or an opt-in `handshake=actas` flow marks bootstrap completion — then prints `status=ready`, so you can send work the moment `spawn` returns without racing the cold start. Use `--no-wait` for fire-and-forget, or `--ready-timeout ` to bound the wait (default 90; actas handshakes have a 300-second minimum based on observed first-turn startup times). On timeout it prints `status=timeout` and exits 3. Drivers with neither handshake return immediately. Options: `--boot-prompt ` (initial task; see above), `--project ` (default: current project), `--team ` (auto-resolved when the project has a single team), and `--terminal ` / `$AGMSG_TERMINAL` / config `spawn.terminal` to override the terminal command on the non-tmux path (a `{cmd}` placeholder is replaced with the path to the generated boot script). On macOS the default opens whichever terminal you're currently in (iTerm or Terminal, via `$TERM_PROGRAM`) using `open -a` — a plain app launch, so it does **not** trigger the Automation/AppleScript permission prompts that scripting the terminal directly would. diff --git a/docs/adr/0003-actas-spawn-readiness-handshake.md b/docs/adr/0003-actas-spawn-readiness-handshake.md new file mode 100644 index 00000000..7caa11e3 --- /dev/null +++ b/docs/adr/0003-actas-spawn-readiness-handshake.md @@ -0,0 +1,93 @@ +# ADR 0003: Opt-in actas-completion handshake for spawned agents + +**Status:** proposed +**Date:** 2026-07-19 +**Deciders:** @fujibee + +## Context + +`agmsg spawn` can hand work to a new agent immediately after launch. For a +driver with a SessionStart-launched watcher, spawn can wait for the existing +long-lived `ready.__` sentinel before returning. Some drivers do +not have that lifecycle hook. Grok Build, for example, starts its watcher only +when the model follows the `actas` instructions, and turn/off delivery modes do +not start a watcher at all. + +Treating watcher readiness as universal caused turn/off Grok spawns to wait for +a signal that could never arrive. Disabling the wait fixed the hang, but brought +back the cold-start race: the parent could send a task before the spawned model +had claimed its identity and processed the first turn. Issue #338 documents +first-turn startup times of 143–187 seconds, so the existing 90-second default +is also too short for an explicit model-driven handshake. + +The agent-type manifest needs a way to declare that its own `actas` flow has a +reliable, explicit completion point without changing any driver that does not +opt in. + +## Decision + +Add the optional agent-type manifest key `handshake=actas`. A driver declaring +it must call `ready.sh mark [nonce]` exactly once after identity +claim and any required delivery setup complete. `spawn.sh` clears that driver's +stale one-shot sentinel before launch, waits for it, consumes it after +observation, and enforces a minimum timeout of 300 seconds. `--no-wait` remains +an explicit opt-out. + +Each `spawn` invocation with `handshake=actas` generates a per-launch nonce and +exports it (with the resolved team) as `AGMSG_SPAWN_TEAM`/`AGMSG_SPAWN_NONCE` +for the template to echo back on mark, and requires the check to see that exact +nonce. Without this, a mark from an abandoned or timed-out earlier launch for +the same (team, agent) — still possible after its own 300s wait gives up, +since the model may keep booting past that point — could satisfy a *later* +spawn's check, reporting `status=ready` before that later launch's own agent +has actually completed bootstrap (review finding, 2026-07-19). + +The one-shot file is named `actas-ready.__` (with the existing +lossless filesystem encoding). It is separate from the watcher-owned +`ready.__` sentinel: the former records one bootstrap edge and is +consumed, while the latter represents live watcher state and remains present +for the watcher lifetime. + +Types without `handshake=actas` retain their current behavior. In particular, +`monitor=yes` types continue using watcher readiness, while `monitor=no` types +continue returning immediately. + +## Alternatives considered + +- **Wait unconditionally for every spawn.** Rejected because drivers without a + watcher or explicit first-turn callback have nothing that can signal; this + recreates the turn/off timeout hang. +- **Make waiting the default and add per-driver opt-outs.** Rejected because a + new default changes every existing and third-party driver. An opt-in key is + backward-compatible and makes the driver author responsible for a real mark + point. +- **Condition waiting on delivery mode.** Rejected because delivery mode is not + bootstrap completion. Turn/off agents still need identity claim before work + can be addressed to them, and a monitor rule merely promises the model will + start a watcher; it does not prove that it has done so. +- **Reuse the watcher `ready.*` sentinel.** Rejected because that file encodes + ongoing watcher liveness and is removed on watcher exit. Treating an + actas-completion edge as liveness would leave ambiguous ownership and stale + state, especially in turn/off modes where no watcher exists. +- **Keep Grok Build fire-and-forget only.** Rejected because it avoids hangs but + preserves the cold-start message race reported in #338. + +## Consequences + +- Positive: opt-in drivers can provide a deterministic handoff point even + without SessionStart hooks or a monitor watcher. +- Positive: existing and external drivers without the key do not change. +- Positive: watcher liveness and one-shot bootstrap completion remain distinct + protocols with distinct filenames and lifetimes. +- Negative: an actas-capable template must faithfully call `ready.sh mark`; if + the model does not follow the instruction, spawn waits until the 300-second + floor expires. +- Negative: the handshake is model-driven, so it cannot be as early or as + deterministic as a native lifecycle hook. +- Neutral: callers that prefer fire-and-forget retain `--no-wait`. + +## References + +- [fujibee/agmsg issue #338](https://github.com/fujibee/agmsg/issues/338), Gap 2 +- Gap 2 design and incident evidence contributed by @Salmonellasarduri +- Existing watcher readiness handshake: issue #108 diff --git a/scripts/drivers/types/grok-build/template.md b/scripts/drivers/types/grok-build/template.md index 41152992..93f1ae22 100644 --- a/scripts/drivers/types/grok-build/template.md +++ b/scripts/drivers/types/grok-build/template.md @@ -133,9 +133,17 @@ If argument starts with "actas" followed by an agent name (e.g. "actas alice"): b. Launch a fresh watcher with the `monitor` tool (persistent): - command: `~/.agents/skills/__SKILL_NAME__/scripts/watch.sh "$GROK_SESSION_ID" "$(pwd)" grok-build ` - description: `agmsg inbox stream` + Wait until the monitor tool confirms that this watcher task has started and + is streaming before continuing. A launch request by itself is not readiness; + do not run step 6 while the watcher is still starting. The 4th argument restricts the subscription to messages addressed to `` only. In `turn`/`off` mode there is no watcher to switch — skip this step. 5. Set the session's active FROM to `` for every `send.sh` call until another `actas`. -6. Tell the user: "Now acting as ``. Sends use `` as from. In monitor mode, receive is restricted to ``; in turn/off mode receive still covers all your registered roles." +6. After the identity claim is complete and, in monitor mode, the monitor tool has confirmed the watcher is running and streaming, signal one-shot spawn readiness exactly once. If this session was launched by `spawn` — the environment variables `AGMSG_SPAWN_TEAM` and `AGMSG_SPAWN_NONCE` are set — use those exactly, quoted, rather than the team resolved in steps 2-3: they identify which team and which specific launch spawn is waiting on (the boot prompt only names the agent, and the same name can be registered under more than one team; the nonce stops a stale mark from an earlier abandoned/timed-out launch from satisfying THIS launch's wait): + `~/.agents/skills/__SKILL_NAME__/scripts/ready.sh mark "$AGMSG_SPAWN_TEAM" '' "$AGMSG_SPAWN_NONCE"` + Otherwise (a manual `/agmsg actas`, not from spawn) use the team resolved in steps 2-3, quoted, with no nonce: + `~/.agents/skills/__SKILL_NAME__/scripts/ready.sh mark '' ''` + This mark says only that actas bootstrap completed; it is not a watcher-liveness signal. +7. Tell the user: "Now acting as ``. Sends use `` as from. In monitor mode, receive is restricted to ``; in turn/off mode receive still covers all your registered roles." If argument starts with "drop" followed by an agent name (e.g. "drop alice"): 1. Parse the role name. diff --git a/scripts/drivers/types/grok-build/type.conf b/scripts/drivers/types/grok-build/type.conf index 34d2028d..634a8e28 100644 --- a/scripts/drivers/types/grok-build/type.conf +++ b/scripts/drivers/types/grok-build/type.conf @@ -7,12 +7,9 @@ model_arg=--model detect=GROK_SESSION_ID detect_proc=grok grok-* hooks_file=.grok/rules/agmsg.md -# monitor=no: spawn skips the readiness handshake for grok-build. Its monitor -# watcher attaches when the agent runs the actas/rule launch (no SessionStart -# hook), so there is no sentinel for `spawn` to await reliably; awaiting it would -# hang a default turn/off-mode spawn (no watcher) until timeout. monitor is still -# offered as a delivery mode below — the delivery_modes gate, not this key, is -# what enables it. A readiness handshake for monitor-mode grok spawns is a -# follow-up tied to the unread catch-up drain (#229). +# Grok has no SessionStart hook, so watcher-based readiness is unavailable in +# turn/off mode. Its actas template explicitly marks one-shot bootstrap +# completion instead (#338 Gap 2). monitor=no +handshake=actas delivery_modes=turn monitor off diff --git a/scripts/lib/actas-lock.sh b/scripts/lib/actas-lock.sh index 7b00f584..dd088c5f 100644 --- a/scripts/lib/actas-lock.sh +++ b/scripts/lib/actas-lock.sh @@ -72,6 +72,17 @@ agmsg_ready_path() { printf '%s/ready.%s__%s' "$(_actas_lock_dir)" "$t" "$a" } +# One-shot actas-completion sentinel path for (team, agent). Unlike +# agmsg_ready_path(), this does NOT represent a live watcher: ready.sh marks it +# once after an agent finishes its actas bootstrap, and spawn consumes it. Keep +# the prefix disjoint from ready.* so watcher GC never mistakes one protocol for +# the other. See #338 Gap 2. +agmsg_actas_ready_path() { + local team="$1" agent="$2" + local t a; t="$(_actas_lock_encode "$team")"; a="$(_actas_lock_encode "$agent")" + printf '%s/actas-ready.%s__%s' "$(_actas_lock_dir)" "$t" "$a" +} + # Placement record path for a spawned (team, agent). `spawn` writes the # member's tmux target id + project + type here at launch time so that # `despawn --force` can tear the member down (kill its pane/window, drop its diff --git a/scripts/ready.sh b/scripts/ready.sh new file mode 100755 index 00000000..fbbcdaa6 --- /dev/null +++ b/scripts/ready.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +# One-shot actas-completion readiness sentinel (#338 Gap 2). +# +# This protocol is intentionally separate from watch.sh's ready.* files. A +# watcher sentinel means "a live exclusive watcher is receiving" and remains +# present for the watcher's lifetime. An actas-ready sentinel means only "the +# spawned agent completed its bootstrap once"; spawn consumes it immediately. +# +# Optional : spawn.sh generates one per launch and exports it as +# AGMSG_SPAWN_NONCE for the template to echo back on mark. Without it, a mark +# from an abandoned/timed-out earlier spawn attempt for the same (team,agent) +# can satisfy a LATER spawn's check — spawn B clears the sentinel and launches, +# but if stale process A (past its own timeout) marks before B does, B's check +# sees A's mark and reports ready before B's own agent has actually booted +# (review finding, 2026-07-19). When a nonce is given to `check`, content must +# match exactly; `mark` without a nonce (e.g. manual `/agmsg actas`, not via +# spawn) still just signals existence, unchanged from before. + +usage() { + echo "Usage: ready.sh mark|check|clear [nonce]" >&2 + exit 1 +} + +ACTION="${1:-}" +TEAM="${2:-}" +AGENT="${3:-}" +NONCE="${4:-}" +{ [ "$#" -eq 3 ] || [ "$#" -eq 4 ]; } || usage +[ -n "$TEAM" ] && [ -n "$AGENT" ] || usage + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# actas-lock.sh consumes this caller-owned variable when sourced. +# shellcheck disable=SC2034 +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/actas-lock.sh" + +READY_PATH="$(agmsg_actas_ready_path "$TEAM" "$AGENT")" + +case "$ACTION" in + mark) + mkdir -p "$(dirname "$READY_PATH")" + # Write then rename in the same directory so check never observes a partial + # sentinel. Content is the caller-supplied nonce when given (diagnostic + + # generation match), else just this process's pid (diagnostic only). + tmp="$(mktemp "$(dirname "$READY_PATH")/.actas-ready.XXXXXX")" + trap 'rm -f "$tmp" 2>/dev/null || true' EXIT + printf '%s\n' "${NONCE:-$$}" > "$tmp" + mv -f "$tmp" "$READY_PATH" + trap - EXIT + ;; + check) + [ -f "$READY_PATH" ] || exit 1 + if [ -n "$NONCE" ]; then + [ "$(tr -d '\r\n' < "$READY_PATH" 2>/dev/null || true)" = "$NONCE" ] + fi + ;; + clear) + rm -f "$READY_PATH" + ;; + *) + usage + ;; +esac diff --git a/scripts/spawn.sh b/scripts/spawn.sh index 3a306d1b..5fb70fac 100755 --- a/scripts/spawn.sh +++ b/scripts/spawn.sh @@ -44,7 +44,8 @@ set -euo pipefail # --no-wait don't block on the readiness handshake; return as soon # as the agent is launched (fire-and-forget) # --ready-timeout N seconds to wait for readiness before giving up -# (default 90; on timeout, prints status=timeout, exit 3) +# (default 90; handshake=actas types use a 300s minimum; +# on timeout, prints status=timeout, exit 3) # --model launch the agent on a specific model. The id is passed # through to the CLI unchecked (the CLI rejects unknown # ids); the flag spelling comes from the type's manifest @@ -62,10 +63,10 @@ set -euo pipefail # ~/.agmsg/config/spawn_options.yaml. Optional; a missing file/section is a # no-op. # -# Readiness: by default spawn blocks until the new agent's watcher attaches and -# is receiving (it prints `status=ready ...`), so a leader can safely send work -# right after spawn returns without racing the agent's cold start. Codex has no -# Monitor, so the wait is skipped for codex. +# Readiness: watcher-capable types block until their watcher attaches. Types +# declaring `handshake=actas` instead block until the agent explicitly marks its +# one-shot bootstrap complete. Other types return immediately. `--no-wait` +# always opts out. # # Scope note: spawnable types are those whose manifest declares `spawnable=yes`; # macOS is the primary target, Linux and @@ -331,6 +332,16 @@ if [ -z "$TEAM" ]; then fi fi +# actas-handshake nonce (#338 Gap 2; review finding, 2026-07-19): computed here, +# before the boot script is assembled below, so it can be exported into it. +# Binds the mark this spawn waits for to THIS launch specifically — see the +# "Readiness handshakes" section further down for the full rationale. +HANDSHAKE="$(agmsg_type_get "$AGENT_TYPE" handshake)" +ACTAS_NONCE="" +if [ "$WAIT_READY" = "1" ] && [ "$HANDSHAKE" = "actas" ]; then + ACTAS_NONCE="$$.${TEAM}.${NAME}" +fi + # Role's session display name (#339): now that TEAM is final, join it to the # agent name. Emitted into the boot script when the type declares name_arg. SESSION_NAME="${TEAM}-${NAME}" @@ -433,6 +444,15 @@ 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' + # actas-handshake types (review finding, 2026-07-19): the boot prompt only + # names the agent, not which team spawn resolved (identities.sh can return + # more than one team for that name) or which launch this is. Export both so + # the template can mark the exact (team, nonce) this spawn is waiting on + # instead of guessing. + if [ -n "$ACTAS_NONCE" ]; then + printf 'export AGMSG_SPAWN_TEAM=%q\n' "$TEAM" + printf 'export AGMSG_SPAWN_NONCE=%q\n' "$ACTAS_NONCE" + 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" @@ -623,31 +643,58 @@ place_and_launch() { echo "spawned ${AGENT_TYPE} '${NAME}' in a new terminal window" } -# Readiness handshake (#108). The spawned agent's actas flow starts its watcher -# in exclusive mode, which touches a ready sentinel once it's actually -# receiving. Block until that appears so the leader doesn't send a job into the -# cold-start window (before the watcher attaches) and lose it. +# Readiness handshakes (#108 watcher, #338 Gap 2 actas). +# +# `handshake=actas` is an explicit driver opt-in. Its template calls ready.sh +# only after identity claim and any monitor setup complete. The resulting +# actas-ready sentinel is one-shot and is consumed below; it is deliberately +# separate from watch.sh's long-lived ready.* liveness sentinel. # -# Types with `monitor=no` do not produce a spawn-awaitable readiness sentinel, so -# skip the wait. That covers types with no Monitor at all (codex) AND types whose -# watcher attaches via the agent's own launch rather than a spawn-time sentinel -# (grok-build, whose monitor mode is real but not awaitable here) — receive there -# is poll-based or agent-launched anyway. -READY_PATH="$(agmsg_ready_path "$TEAM" "$NAME")" -if [ "$(agmsg_type_get "$AGENT_TYPE" monitor)" = "no" ] && [ "$WAIT_READY" = "1" ]; then - WAIT_READY=0 - echo "spawn: '$AGENT_TYPE' has no spawn readiness handshake — skipping readiness wait (--no-wait implied)" >&2 +# Types without handshake=actas retain the existing monitor=yes/no behavior. +# This preserves Claude Code's watcher wait while keeping no-monitor types +# immediate. --no-wait opts out before either protocol is selected. +# +# HANDSHAKE/ACTAS_NONCE are already computed above (before the boot script is +# assembled, so the nonce can be exported into it); reused here as-is. +READY_KIND="" +READY_PATH="" +if [ "$WAIT_READY" = "1" ]; then + if [ "$HANDSHAKE" = "actas" ]; then + READY_KIND="actas" + if [ "$READY_TIMEOUT" -lt 300 ]; then + echo "spawn: '$AGENT_TYPE' actas handshake uses a 300s minimum readiness timeout (requested ${READY_TIMEOUT}s)" >&2 + READY_TIMEOUT=300 + fi + elif [ "$(agmsg_type_get "$AGENT_TYPE" monitor)" = "no" ]; then + WAIT_READY=0 + echo "spawn: '$AGENT_TYPE' has no spawn readiness handshake — skipping readiness wait (--no-wait implied)" >&2 + else + READY_KIND="watcher" + READY_PATH="$(agmsg_ready_path "$TEAM" "$NAME")" + fi fi # Clear any stale sentinel before launching so we only observe THIS spawn's -# watcher attaching. -[ "$WAIT_READY" = "1" ] && rm -f "$READY_PATH" 2>/dev/null || true +# bootstrap. Actas readiness is cleared through its public command; watcher +# readiness retains its existing direct-path protocol. +if [ "$WAIT_READY" = "1" ]; then + if [ "$READY_KIND" = "actas" ]; then + "$SCRIPT_DIR/ready.sh" clear "$TEAM" "$NAME" + else + rm -f "$READY_PATH" 2>/dev/null || true + fi +fi place_and_launch if [ "$WAIT_READY" = "1" ]; then waited=0 - while [ ! -e "$READY_PATH" ]; do + while true; do + if [ "$READY_KIND" = "actas" ]; then + "$SCRIPT_DIR/ready.sh" check "$TEAM" "$NAME" "$ACTAS_NONCE" && break + elif [ -e "$READY_PATH" ]; then + break + fi if [ "$waited" -ge "$READY_TIMEOUT" ]; then echo "status=timeout name=${NAME} team=${TEAM} after=${READY_TIMEOUT}s" echo "spawn: '${NAME}' did not signal ready within ${READY_TIMEOUT}s — it may still be booting; re-spawn or raise --ready-timeout" >&2 @@ -656,5 +703,8 @@ if [ "$WAIT_READY" = "1" ]; then sleep 1 waited=$((waited + 1)) done + # Actas completion is an edge, not liveness. Consume it immediately so a + # later spawn can never mistake this bootstrap for its own. + [ "$READY_KIND" = "actas" ] && "$SCRIPT_DIR/ready.sh" clear "$TEAM" "$NAME" echo "status=ready name=${NAME} team=${TEAM} after=${waited}s" fi diff --git a/tests/test_install.bats b/tests/test_install.bats index 145a4349..74007e3b 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -24,6 +24,12 @@ teardown() { @test "install: fresh install ships scripts/lib and the commands actually run" { HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg [ -f "$SK/scripts/lib/storage.sh" ] + [ -x "$SK/scripts/ready.sh" ] + + # The opt-in actas handshake is part of the installed public command set. + bash "$SK/scripts/ready.sh" mark demo alice + bash "$SK/scripts/ready.sh" check demo alice + bash "$SK/scripts/ready.sh" clear demo alice # End-to-end through the installed scripts — a missing sourced helper would # surface here, not just as a stat on a file. diff --git a/tests/test_ready.bats b/tests/test_ready.bats new file mode 100644 index 00000000..a643c71e --- /dev/null +++ b/tests/test_ready.bats @@ -0,0 +1,81 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_test_env +} + +teardown() { + teardown_test_env +} + +@test "ready: mark makes check succeed, and clear consumes the sentinel" { + run bash "$SCRIPTS/ready.sh" check team alice + [ "$status" -ne 0 ] + + run bash "$SCRIPTS/ready.sh" mark team alice + [ "$status" -eq 0 ] + + run bash "$SCRIPTS/ready.sh" check team alice + [ "$status" -eq 0 ] + + run bash "$SCRIPTS/ready.sh" clear team alice + [ "$status" -eq 0 ] + + run bash "$SCRIPTS/ready.sh" check team alice + [ "$status" -ne 0 ] +} + +@test "ready: clear is idempotent when no sentinel exists" { + run bash "$SCRIPTS/ready.sh" clear team missing + [ "$status" -eq 0 ] +} + +@test "ready: actas sentinel is distinct from watcher readiness" { + bash "$SCRIPTS/ready.sh" mark team alice + + [ -f "$TEST_SKILL_DIR/run/actas-ready.team__alice" ] + [ ! -e "$TEST_SKILL_DIR/run/ready.team__alice" ] +} + +@test "ready: filesystem encoding prevents lossy name collisions" { + bash "$SCRIPTS/ready.sh" mark 'team one' 'alice/bob' + bash "$SCRIPTS/ready.sh" mark 'team_one' 'alice_bob' + + run bash "$SCRIPTS/ready.sh" check 'team one' 'alice/bob' + [ "$status" -eq 0 ] + run bash "$SCRIPTS/ready.sh" check 'team_one' 'alice_bob' + [ "$status" -eq 0 ] + + bash "$SCRIPTS/ready.sh" clear 'team one' 'alice/bob' + run bash "$SCRIPTS/ready.sh" check 'team_one' 'alice_bob' + [ "$status" -eq 0 ] +} + +@test "ready: mark is idempotent and refreshes the same sentinel" { + bash "$SCRIPTS/ready.sh" mark team alice + local count_before + count_before="$(find "$TEST_SKILL_DIR/run" -name 'actas-ready.*' | wc -l | tr -d ' ')" + + bash "$SCRIPTS/ready.sh" mark team alice + local count_after + count_after="$(find "$TEST_SKILL_DIR/run" -name 'actas-ready.*' | wc -l | tr -d ' ')" + + [ "$count_before" -eq 1 ] + [ "$count_after" -eq 1 ] +} + +@test "ready: rejects unknown actions and malformed argv" { + run bash "$SCRIPTS/ready.sh" nope team alice + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] + + run bash "$SCRIPTS/ready.sh" mark team + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] + + run bash "$SCRIPTS/ready.sh" mark '' alice + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} diff --git a/tests/test_spawn.bats b/tests/test_spawn.bats index 681e6948..46dcc106 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -342,7 +342,7 @@ seed_resumable() { } @test "spawn: grok-build launches the plain grok CLI with the actas prompt" { - # grok-build is spawnable and monitor=no, so spawn skips the readiness wait. + # --no-wait makes this test independent of the project's delivery mode. # Delivery is a rule file (no hook), so no folder-trust flag is needed — # the launch is the bare `grok "/ actas "`, like claude-code. bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" @@ -785,19 +785,84 @@ YAML [[ "$output" == *"skipping readiness wait"* ]] } -@test "spawn: grok-build skips the readiness wait even without --no-wait (monitor=no)" { - # Regression guard: grok-build's monitor watcher attaches via the agent's - # actas/rule launch (no SessionStart hook) and only in monitor mode, so there - # is no ready sentinel for spawn to await. With monitor=no, spawn must skip the - # wait and return immediately instead of hanging a default turn/off-mode spawn - # until --ready-timeout. (Without this, monitor=yes made the wait fire.) +@test "spawn: grok-build waits for and consumes the one-shot actas handshake" { bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + + # Simulate the agent completing its actas template after launch: read the + # spawn-exported team/nonce back out of the generated boot script (the same + # thing the real template does via $AGMSG_SPAWN_TEAM/$AGMSG_SPAWN_NONCE), + # then mark with them — a bare mark without the nonce would no longer + # satisfy spawn's check (review finding, 2026-07-19). This uses ready.sh + # rather than watch.sh's distinct, long-lived ready.* sentinel. + local mark_helper="$TEST_SKILL_DIR/mark-helper.sh" + cat > "$mark_helper" < "$STUB_BIN/sleep" + chmod +x "$STUB_BIN/sleep" + + run env -u TMUX bash "$SCRIPTS/spawn.sh" grok-build alice --project "$PROJ" \ + --ready-timeout 2 --terminal "true # {cmd}" + [ "$status" -eq 3 ] + [[ "$output" == *"status=timeout"* ]] + [[ "$output" != *"status=ready"* ]] +} + +@test "spawn: actas handshake clears stale state and enforces the 300s timeout floor" { + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + bash "$SCRIPTS/ready.sh" mark myteam alice + + # Avoid a real five-minute wait: each loop still increments one logical + # second, but this test-local sleep returns immediately. + printf '#!/usr/bin/env bash\nexit 0\n' > "$STUB_BIN/sleep" + chmod +x "$STUB_BIN/sleep" + + run env -u TMUX bash "$SCRIPTS/spawn.sh" grok-build alice --project "$PROJ" \ + --ready-timeout 2 --terminal "true # {cmd}" + [ "$status" -eq 3 ] + [[ "$output" == *"300s minimum"* ]] + [[ "$output" == *"status=timeout"* ]] + [[ "$output" == *"after=300s"* ]] + [[ "$output" != *"status=ready"* ]] +} + +@test "spawn: a no-monitor type without handshake key still returns immediately" { + bash "$SCRIPTS/join.sh" myteam existing claude-code "$PROJ" + + # Model an older/external grok driver: no handshake key and monitor=no. + awk '!/^handshake=/' "$TYPES/grok-build/type.conf" > "$TYPES/grok-build/type.conf.tmp" + mv "$TYPES/grok-build/type.conf.tmp" "$TYPES/grok-build/type.conf" + run env -u TMUX bash "$SCRIPTS/spawn.sh" grok-build alice --project "$PROJ" \ --terminal "true # {cmd}" [ "$status" -eq 0 ] [[ "$output" == *"skipping readiness wait"* ]] - [[ "$output" != *"status=timeout"* ]] - [[ "$output" != *"status=ready"* ]] + [[ "$output" != *"status="* ]] } # --- initial prompt (--boot-prompt) --- diff --git a/tests/test_type_registry.bats b/tests/test_type_registry.bats index 8c8e38f6..4cdd1f71 100644 --- a/tests/test_type_registry.bats +++ b/tests/test_type_registry.bats @@ -54,6 +54,17 @@ write_node_launcher_fixtures() { [ "$output" = "FALLBACK" ] } +@test "type-registry: actas handshake is explicit opt-in" { + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get grok-build handshake" + [ "$status" -eq 0 ] + [ "$output" = "actas" ] + + # Missing key remains empty, preserving existing driver behavior. + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex handshake" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + @test "type-registry: template_path resolves to the type dir's template.md" { run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_template_path codex" [ "$status" -eq 0 ]