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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ Where `actas` switches *this* session to a different role, `spawn` brings up a *

Pass `--boot-prompt <text>` 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 <secs>` 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 <secs>` 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 <text>` (initial task; see above), `--project <path>` (default: current project), `--team <team>` (auto-resolved when the project has a single team), and `--terminal <tmpl>` / `$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.

Expand Down
93 changes: 93 additions & 0 deletions docs/adr/0003-actas-spawn-readiness-handshake.md
Original file line number Diff line number Diff line change
@@ -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.<team>__<agent>` 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 <team> <agent> [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.<team>__<agent>` (with the existing
lossless filesystem encoding). It is separate from the watcher-owned
`ready.<team>__<agent>` 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
10 changes: 9 additions & 1 deletion scripts/drivers/types/grok-build/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`
- 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 `<name>` only. In `turn`/`off` mode there is no watcher to switch — skip this step.
5. Set the session's active FROM to `<name>` for every `send.sh` call until another `actas`.
6. Tell the user: "Now acting as `<name>`. Sends use `<name>` as from. In monitor mode, receive is restricted to `<name>`; 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" '<name>' "$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 '<team>' '<name>'`
This mark says only that actas bootstrap completed; it is not a watcher-liveness signal.
7. Tell the user: "Now acting as `<name>`. Sends use `<name>` as from. In monitor mode, receive is restricted to `<name>`; 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.
Expand Down
11 changes: 4 additions & 7 deletions scripts/drivers/types/grok-build/type.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions scripts/lib/actas-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions scripts/ready.sh
Original file line number Diff line number Diff line change
@@ -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 <nonce>: 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 <team> <agent> [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
Loading
Loading