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.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ codex:
/agmsg despawn alice --force # 強制: ウォッチャーが応答できない場合にここから終了させる
```

デフォルトで `despawn <name>` は**グレースフル**だ — `<name>` に `ctrl:despawn` 制御メッセージを送信し、そのウォッチャーが自分のロール(actasロックと登録の解放)を解除し、自分のtmuxペインを閉じてエージェントを終了させる。ロールが解放されるまでブロックし、`--timeout <secs>`(デフォルト30)が上限、その後 `status=ok` を表示する。メンバーのウォッチャーが応答しない場合は `status=timeout` を表示して終了コード3 — `--force` で再試行すること
デフォルトで `despawn <name>` は**グレースフル**だ — `<name>` に `ctrl:despawn` 制御メッセージを送信し、そのウォッチャーが自分のロール(actasロックと登録の解放)を解除し、自分のtmuxペインを閉じてエージェントを終了させる。対象のロックが解放され、かつそのウォッチャーがすでにレディネスの目印を公開していた場合はその目印が消えるまでブロックしてから、`status=ok` を表示する。このレディネス確認は保守的なライフサイクルの証拠であり、配置世代を証明するものではない。`--timeout <secs>`(デフォルト30)が上限で、タイムアウト時は `status=timeout` を表示して終了コード3となるウォッチャー/レジストリの復旧後にグレースフルで再試行し、`--force` は同名の登録がほかに残っていない場合に使う

`--force` はメッセージ送信をスキップし、spawn時に記録された配置情報からメンバーを終了させる — メンバーのtmuxペイン/ウィンドウをkillし、登録を削除する。メンバーのウォッチャーが応答できない場合(ウォッチャーが死んでいる、または**codex**メンバー — Monitorがないためグレースフルには何も反応するものがない)に使う。手動で起動されたメンバー(spawnの配置記録がない)は `--force` できない — despawnがその旨を伝え、あなた自身が閉じることになる。

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ Eight of the nine agent types are spawnable — `claude-code`, `codex`, `grok-bu
/agmsg despawn alice --force # force: tear it down from here when its watcher can't respond
```

By default `despawn <name>` is **graceful**: it sends a `ctrl:despawn` control message to `<name>`, whose watcher drops its own role (releasing the actas lock and registration) and closes its own tmux pane — ending the agent. It blocks until the role is released, up to `--timeout <secs>` (default 30), then prints `status=ok`. If the member's watcher never responds it prints `status=timeout` and exits 3 — retry with `--force`.
By default `despawn <name>` is **graceful**: it sends a `ctrl:despawn` control message to `<name>`, whose watcher drops its own role (releasing the actas lock and registration) and closes its own tmux pane — ending the agent. It blocks until the target lock is free and, if that watcher had already published a readiness sentinel, the sentinel is gone; then it prints `status=ok`. The readiness check is conservative lifecycle evidence, not a placement-generation proof. It waits up to `--timeout <secs>` (default 30); on timeout it prints `status=timeout` and exits 3 — retry graceful after watcher/registry recovery; use `--force` only when no other same-name registration remains.

`--force` skips the message and tears the member down from the placement recorded at spawn time: it kills the member's tmux pane/window and drops its registration. Use it when the member's watcher can't respond — a dead watcher, or a **codex** member (no Monitor, so graceful has nothing to act on). A member started by hand (no spawn placement record) can't be `--force`d; despawn says so and leaves it for you to close.

Expand Down
16 changes: 10 additions & 6 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,18 @@ Do NOT manually edit config files. Always use join.sh. If the name was recently
# Tear down a spawned member — the inverse of spawn.
# Default (graceful): sends a `ctrl:despawn` control message to <name>; the
# member's watcher drops its own role (releasing the actas lock + registration)
# and closes its own tmux pane, ending the agent. Blocks until the lock releases
# (--timeout, default 30s) then prints `status=ok`; on timeout prints
# status=timeout and exits 3 (retry with --force). Only an exclusive watcher
# dedicated to <name> acts on it — the despawning session is never torn down.
# and closes its own tmux pane, ending the agent. It reports `status=ok` only
# after the target actas lock is free and, when a readiness sentinel existed at
# teardown start, that sentinel is absent. This is conservative lifecycle
# evidence, not a placement-generation proof. On timeout (`--timeout`, default
# 30s), retry graceful after watcher/registry recovery; use --force only when
# no other same-name registration remains. Only an exclusive watcher dedicated
# to <name> acts on it — the despawning session is never torn down.
# --force: skip the message and tear the member down from the placement recorded
# at spawn time (kill its tmux pane/window, drop its registration) — for a dead
# watcher or a codex member (no Monitor). A hand-started member with no placement
# record can't be --forced.
# watcher or a codex member (no watcher/Monitor, so it normally needs --force,
# subject to the same-name-registration guard). A hand-started member with no
# placement record can't be --forced.
# --force tear down from the recorded placement, no message
# --timeout N seconds to wait for graceful teardown (default 30)
~/.agents/skills/agmsg/scripts/despawn.sh <team> <from> <name> [--force] [--timeout N]
Expand Down
65 changes: 65 additions & 0 deletions docs/actas.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ The lock is released by `drop`, by session end, or by garbage collection when th

If `<name>` was the currently-active role, the watcher is restarted in default mode — no `actas` name filter, so it receives every `(team, agent)` pair registered for this project that isn't held by another session.

If checked lock cleanup cannot run (for example, reclaim SQLite is unavailable
or a legacy `.reclaim.d` marker remains), `drop` exits nonzero. In the normal
checked-cleanup failure path, it restores the prior registration while retaining
the lock so retry is safe after recovery; the diagnostic includes
`retained=<team>/<name>` (or a comma-separated exact set). If that restoration
write also fails, the command still exits nonzero and reports the retained pair,
but inspect the team registry before retrying. Do not treat either outcome as a
successful drop or remove the lock by hand—restore the reported infrastructure
first.

## Session scope

Switching is session-scoped state held by the agent. `/clear` or a new session resets back to the multiple-identities picker.
Expand All @@ -42,6 +52,61 @@ To unstick:

Either releases the lock so peers can pick it up.

### Protocol upgrade and rollback boundary

The current SQLite reclaim mutex and the older `.reclaim.d` mutator are not a
mixed-version protocol. Before the first new-protocol mutation, stop every old
agmsg agent, watcher, hook, and in-flight lock helper; upgrade every such
process consistently; then restart them on the new version. Do not run old and
new mutators together.

Before rolling back code, first quiesce every new-protocol helper and process.
Retain and inspect `run/actas-reclaim.db`; do not delete it to force a rollback
or infer that its rows are stale from their age. Only after the helpers are
known to have stopped should all processes move back to the older version.

Legacy marker detection is diagnostic and fail-closed, not proof that the
system is quiescent: an old mutator can begin after a new helper's final marker
check. The absence of a marker likewise does not authorize a mixed-version
operation.

### Legacy `.reclaim.d` transition

Older agmsg versions serialized stale-lock removal with an empty directory next
to the lock, named `actas.<team>__<name>.session.reclaim.d`. An empty directory
cannot reveal whether its old owner crashed or is merely paused, so the current
version deliberately fails closed when one exists. SessionStart prints each
affected path and never deletes it automatically.

To recover safely:

1. Stop every agent, watcher, hook, or other agmsg process that could still be
running the old lock mutator, then restart them on the upgraded version.
2. Inspect the exact paths reported by SessionStart. Confirm each is the
affected empty `.reclaim.d` directory and that no old mutator remains able to
enter it.
3. Remove only those confirmed empty directories. Do not recursively remove
the surrounding lock or run directory.
4. Retry `actas` or restart the affected session.

Never infer safety from a marker's age and never run age-based cleanup. A live
old mutator may remain paused for an arbitrary amount of time.

### Incomplete multi-team rollback

A name may be registered in more than one team, so `actas-claim.sh` can claim
one pair before a later pair encounters a filesystem or SQLite failure. Group
claim and rollback are not atomic. The script aborts before changing the
Monitor or recording role affinity and attempts a mutex-protected rollback. If
that same infrastructure prevents cleanup, the error includes
`rollback=incomplete locked=<pairs>` with the exact retained pairs.

Do not delete those lock files directly. Restore the reported infrastructure
and retry so checked cleanup can finish. Until infrastructure recovers—or a
future #519 generation-bearing record protocol provides stronger fencing—the
safe behavior is to diagnose and retain those locks, not claim rollback
completed.

## Liveness and PID recycling

A stale lock is reclaimed when its owner session_id no longer maps to any live cc-instance, where "live" is checked via `kill -0`.
Expand Down
75 changes: 60 additions & 15 deletions scripts/actas-claim.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,26 @@ set -euo pipefail
#
# Output (stdout, key=value lines):
# status=ok team=<team> [team=<team2> ...] everything claimed
# status=held team=<team> owner=<owner_sid> refused — another live session owns it
# status=held team=<team> owner=<owner_sid> [rollback=incomplete locked=<pairs>]
# refused — another live session owns it
# status=not_registered name is not joined to any team in this project/type
# status=error team=<team> reason=<reason> [rollback=incomplete locked=<pairs>]
# On held or error, `locked` is the exact comma-separated set of percent-encoded
# team/agent pairs that the checked best-effort rollback could not prove released.
#
# Exit code:
# 0 — status=ok
# 1 — status=held (callers should NOT proceed with the actas flow)
# 2 — status=not_registered (callers should run join.sh first)
# 3 — status=error (claim/reclaim infrastructure failed; do not proceed)

PROJECT="${1:?Usage: actas-claim.sh <project> <type> <name> <session_id>}"
TYPE="${2:?Missing type}"
NAME="${3:?Missing name}"
SESSION_ID="${4:?Missing session_id}"

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC2034 # consumed by sourced actas/role-session libraries
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # actas-lock.sh requires SKILL_DIR
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/actas-lock.sh"
Expand Down Expand Up @@ -62,24 +68,63 @@ if [ -z "$TEAMS" ]; then
fi

# Attempt claim for each matching team. First failure aborts and reports the
# offending team — callers should resolve that before retrying. Releases
# already-claimed pairs in this same attempt so partial state doesn't leak.
claimed=""
# offending team — callers should resolve that before retrying. Rollback is
# checked but necessarily best-effort: if the same infrastructure is down, the
# exact retained pairs are reported rather than deleted unsafely. Only locks
# that were free before this attempt are included; a pre-existing lock already
# owned by this session is not disturbed.
newly_claimed=""

while IFS= read -r team; do
[ -z "$team" ] && continue
result=$(actas_lock_claim "$team" "$NAME" "$SESSION_ID" 2>/dev/null || true)
pre_state="$(actas_lock_state "$team" "$NAME" "$SESSION_ID")"
claim_status=0
if result=$(actas_lock_claim "$team" "$NAME" "$SESSION_ID" 2>/dev/null); then
claim_status=0
else
claim_status=$?
fi

if [ "$claim_status" -eq 0 ] && [ -z "$result" ]; then
[ "$pre_state" = "free" ] \
&& newly_claimed="${newly_claimed:+$newly_claimed$'\n'}${team}"$'\t'"${NAME}"
continue
fi

rollback="$newly_claimed"
# A write may have linked the record before a later temp-cleanup failure.
# Releasing a free-before-attempt pair is safe: release compares our exact
# owner, so it cannot remove a peer that won the race.
[ "$pre_state" = "free" ] \
&& rollback="${rollback:+$rollback$'\n'}${team}"$'\t'"${NAME}"
rollback_incomplete=0
if rollback_locked="$(actas_lock_rollback_pairs "$rollback" "$SESSION_ID")"; then
rollback_incomplete=0
else
rollback_incomplete=1
fi

if [ "$claim_status" -eq 1 ]; then
case "$result" in
held:*)
printf 'status=held team=%s owner=%s' "$team" "${result#held:}"
[ "$rollback_incomplete" -eq 1 ] \
&& printf ' rollback=incomplete locked=%s' "$rollback_locked"
printf '\n'
exit 1
;;
esac
fi

case "$result" in
held:*)
# Roll back any partial claims so the user can retry cleanly.
while IFS= read -r c_team; do
[ -z "$c_team" ] && continue
actas_lock_release "$c_team" "$NAME" "$SESSION_ID" 2>/dev/null || true
done <<< "$claimed"
printf 'status=held team=%s owner=%s\n' "$team" "${result#held:}"
exit 1
;;
error:*) reason="${result#error:}" ;;
*) reason="claim-protocol" ;;
esac
claimed="${claimed:+$claimed$'\n'}$team"
printf 'status=error team=%s reason=%s' "$team" "$reason"
[ "$rollback_incomplete" -eq 1 ] \
&& printf ' rollback=incomplete locked=%s' "$rollback_locked"
printf '\n'
exit 3
done <<< "$TEAMS"

# All teams claimed. Record (team, agent) -> bare session id for each, so this
Expand Down
Loading
Loading