From fad8962011dfdfa2f0f13e817c18ce504cbdde99 Mon Sep 17 00:00:00 2001 From: tmad4000 Date: Wed, 29 Jul 2026 02:01:13 -0700 Subject: [PATCH 1/8] feat: add recoverable primary session handoff (fm-session-kick-handoff) --- .../skills/primary-session-handoff/SKILL.md | 51 ++ AGENTS.md | 2 + README.md | 3 + bin/fm-lock.sh | 11 + bin/fm-primary-session-lib.sh | 182 +++++ bin/fm-primary-session.sh | 704 ++++++++++++++++++ bin/fm-session-lock-lib.sh | 102 ++- bin/fm-test-run.sh | 10 +- docs/configuration.md | 5 +- docs/documentation-audiences.json | 16 + docs/primary-session-handoff.md | 62 ++ docs/scripts.md | 2 + docs/verification/primary-session-handoff.md | 57 ++ tests/fm-claude-stop-autoarm.test.sh | 1 + tests/fm-primary-session.test.sh | 618 +++++++++++++++ tests/fm-session-start.test.sh | 8 + 16 files changed, 1816 insertions(+), 18 deletions(-) create mode 100644 .agents/skills/primary-session-handoff/SKILL.md create mode 100644 bin/fm-primary-session-lib.sh create mode 100755 bin/fm-primary-session.sh create mode 100644 docs/primary-session-handoff.md create mode 100644 docs/verification/primary-session-handoff.md create mode 100755 tests/fm-primary-session.test.sh diff --git a/.agents/skills/primary-session-handoff/SKILL.md b/.agents/skills/primary-session-handoff/SKILL.md new file mode 100644 index 0000000000..69e8e6bcc8 --- /dev/null +++ b/.agents/skills/primary-session-handoff/SKILL.md @@ -0,0 +1,51 @@ +--- +name: primary-session-handoff +description: >- + Agent-only procedure for scanning primary-provider captain actions and safely transferring or restoring the live Firstmate primary session. + Use after a live session-lock refusal when the current provider is idle but unavailable, or whenever primary takeover, suspension, restoration, or a fleet-wide permission scan is requested. +user-invocable: false +metadata: + internal: true +--- + +# primary-session-handoff + +Use this procedure after a live session-lock refusal when the current provider is unavailable, or whenever primary takeover, suspension, restoration, or a fleet-wide permission scan is requested. + +Load `harness-adapters` before this skill performs a provider suspend or resume. +Read `bin/fm-primary-session.sh --help` before the first command because that script owns supported providers, exact mechanics, receipt states, and flags. + +## Read-only triage + +Run the command's fleet-wide scan before considering a transfer. +The scan reports structured pending permissions and captain-action markers without prompt, title, or permission prose. +Treat every listed action as unresolved until the owning session or captain resolves it. + +Identify the exact external session requested for takeover from the visible provider inventory. +Do not infer identity from a title, transcript, terminal label, provider name, or working directory. + +## Takeover + +Invoke the owned takeover command with the exact external session id. +Do not call a provider archive command separately, pass a force flag, delete `state/.lock`, or edit the owner descriptor. + +Only `idle` and `paused-rate-limited` are eligible outcomes. +`waiting-on-captain`, `busy`, `wedged`, `unknown`, and `unsupported` are refusals, not invitations to approximate the operation manually. + +After success, read the ordinary session-start digest once and follow its emitted supervision block. +Preserve the printed receipt path so the suspended provider can be restored later. + +## Restore + +Restore only from the receipt created by the owned takeover command. +Ensure the successor primary has exited normally before requesting restoration, because a live or ambiguous lock owner is a hard refusal. + +Invoke the owned restore command and do not call the provider reload command separately. +The reloaded provider remains mutation-ineligible until its native session-start nudge runs the ordinary lock-acquisition contract. +If another primary wins that acquisition, the restored provider stays read-only and reports the normal lock refusal. + +## Unsupported or failed operations + +Report an unsupported combination with the command's exact classification. +Leave the provider, numeric lock, owner descriptor, receipt, fleet state, task worktrees, and wake queue untouched after a refusal. +Never turn an incomplete suspension receipt into permission to delete or overwrite a live lock. diff --git a/AGENTS.md b/AGENTS.md index 82bcbe8e55..2cf1bad41e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,7 @@ An `ABSENT` captain, shared-captain, secondmate, or learnings file means the fir If the session lock cannot be acquired and verified, report its exact diagnostic and remain read-only; another active session is only one possible cause. A lock-refused session must not spawn, steer, merge, drain the wake queue, repair supervision, repair a checkout, or perform any other fleet mutation. +When an explicit primary-provider takeover or restoration is required, load `primary-session-handoff`; only its owned command may cross this read-only boundary, and it never authorizes deleting or weakening a live owner's lock. 1. **Lock** - acquires the per-home session lock first, before anything mutates shared state. 2. **Bootstrap** - detect-only checks (tool/version problems, GitHub auth, the worktree-tangle check, harness override, dispatch-profile validation, backlog-backend status) always run, but routine confirmations stay silent by default. @@ -483,6 +484,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. +- `primary-session-handoff` - load after a live session-lock refusal when the current provider is unavailable, and before primary takeover, suspension, restoration, or a fleet-wide provider permission scan. ## 14. X mode diff --git a/README.md b/README.md index a7f69e39c2..64e84f874b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; dry-run preview records would-be replies and dismissals locally before go-live. - **Guarded by construction** - the first mate is read-only over your projects except for the guarded paths authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), with fleet sync's safe branch pruning remaining part of the fleet-sync exception; crewmates make every project change behind the configured merge authority. - **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on. +- **Recoverable primary handoff** - safely transfer the live lock away from an idle local Paseo-hosted Claude or Codex provider, then explicitly restore its preserved provider session after the successor exits. Full detail on every feature lives in [docs/architecture.md](docs/architecture.md). @@ -203,6 +204,8 @@ Firstmate's skills live in two separate places with different audiences: - [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for GitLab merge watching on arbitrary instances. - [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's current "no turn ends blind" backstop, scope, loop safety, and compatibility limits. - [docs/verification/supervision.md](docs/verification/supervision.md) - active maintainer verification for session-start, guard, continuity, and wedge integrations. +- [docs/primary-session-handoff.md](docs/primary-session-handoff.md) - current safe takeover, restoration, permission-scan, and supported-provider contract. +- [docs/verification/primary-session-handoff.md](docs/verification/primary-session-handoff.md) - deterministic and incident-derived maintainer verification for recoverable primary handoff. - [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and `pi-signed`, Grok, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. - [docs/documentation-audiences.md](docs/documentation-audiences.md) - documentation audiences and the machine-checked placement boundary. diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 083675b2be..49e698fe52 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -5,6 +5,11 @@ # PID of any one tool call, which is dead moments after it is written. # Usage: fm-lock.sh acquire; exit 1 unless ownership is verified # fm-lock.sh status print holder and liveness; always exits 0 +# A successful acquisition also atomically publishes state/.lock.owner. +# That privacy-safe descriptor records the numeric owner, process-identity +# hash, verified harness, visible-session manager, external session id, and +# provider-native session id when available. +# The numeric state/.lock remains the sole orchestration authority. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -22,6 +27,8 @@ mkdir -p "$STATE" 2>/dev/null || { # same identity contract. # shellcheck source=bin/fm-session-lock-lib.sh . "$SCRIPT_DIR/fm-session-lock-lib.sh" +# shellcheck source=bin/fm-primary-session-lib.sh +. "$SCRIPT_DIR/fm-primary-session-lib.sh" if [ "${1:-}" = "status" ]; then if [ ! -f "$LOCK" ]; then echo "lock: free"; exit 0; fi @@ -83,5 +90,9 @@ if [ ! -f "$LOCK" ] || [ -L "$LOCK" ] || [ "$written" != "$me" ]; then echo "error: session lock ownership verification failed; operate read-only until resolved" >&2 exit 1 fi +if ! fm_primary_owner_publish "$STATE" "$me"; then + echo "error: cannot publish verified primary-session owner metadata; lock remains held by this live session and it must operate read-only until resolved" >&2 + exit 1 +fi release_claim_lock echo "lock acquired: harness pid $me" diff --git a/bin/fm-primary-session-lib.sh b/bin/fm-primary-session-lib.sh new file mode 100644 index 0000000000..17543ae172 --- /dev/null +++ b/bin/fm-primary-session-lib.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Shared primary-session owner descriptor and handoff-receipt helpers. +# +# This library does not acquire, replace, release, suspend, or restore anything. +# bin/fm-lock.sh calls fm_primary_owner_publish only after the numeric session +# lock has been claimed and verified through its canonical path. +# bin/fm-primary-session.sh owns the Paseo lifecycle transaction and uses the +# receipt helpers while holding state/.lock.acquire. +# This file is sourced by scripts and has no side effects on source. + +FM_PRIMARY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_PRIMARY_LIB_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_PRIMARY_LIB_DIR/.." && pwd)}" +FM_PRIMARY_LIB_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_PRIMARY_LIB_ROOT}}" +FM_PRIMARY_LIB_DATA="${FM_DATA_OVERRIDE:-$FM_PRIMARY_LIB_HOME/data}" + +fm_primary_atom_valid() { + local value=$1 + [ -n "$value" ] && [ "${#value}" -le 255 ] \ + && LC_ALL=C grep -Eq '^[A-Za-z0-9][A-Za-z0-9._:@+_-]*$' <<< "$value" +} + +fm_primary_sha256_text() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + else + return 1 + fi +} + +fm_primary_home_fingerprint() { + local resolved + resolved=$(cd "$FM_PRIMARY_LIB_HOME" 2>/dev/null && pwd -P) || return 1 + printf '%s' "$resolved" | fm_primary_sha256_text +} + +fm_primary_now_utc() { + date -u '+%Y-%m-%dT%H:%M:%SZ' +} + +fm_primary_receipt_last_value() { + local receipt=$1 key=$2 + [ -f "$receipt" ] && [ ! -L "$receipt" ] || return 1 + sed -n "s/^${key}=//p" "$receipt" | tail -1 +} + +fm_primary_receipt_append_state() { + local receipt=$1 state=$2 at_key=$3 at_value=$4 + [ -f "$receipt" ] && [ ! -L "$receipt" ] || return 1 + fm_primary_atom_valid "$state" || return 1 + fm_primary_atom_valid "$at_key" || return 1 + [ -n "$at_value" ] && LC_ALL=C grep -Eq '^[A-Za-z0-9:TZ.+-]+$' <<< "$at_value" || return 1 + { + printf '%s=%s\n' "$at_key" "$at_value" + printf 'state=%s\n' "$state" + } >> "$receipt" +} + +fm_primary_paseo_home() { + if [ -n "${PASEO_HOME:-}" ]; then + printf '%s\n' "$PASEO_HOME" + else + printf '%s/.paseo\n' "${HOME:?HOME is required to resolve the local Paseo home}" + fi +} + +fm_primary_paseo_agent_file() { + local agent_id=$1 paseo_home direct candidate match='' count=0 + fm_primary_atom_valid "$agent_id" || return 1 + paseo_home=$(fm_primary_paseo_home) || return 1 + direct="$paseo_home/agents/$agent_id.json" + if [ -f "$direct" ] && [ ! -L "$direct" ]; then + printf '%s\n' "$direct" + return 0 + fi + for candidate in "$paseo_home"/agents/*/"$agent_id.json"; do + [ -f "$candidate" ] && [ ! -L "$candidate" ] || continue + match=$candidate + count=$(( count + 1 )) + done + [ "$count" -eq 1 ] || return 1 + printf '%s\n' "$match" +} + +fm_primary_provider_session_from_paseo_state() { + local agent_id=$1 agent_file value + command -v jq >/dev/null 2>&1 || return 1 + agent_file=$(fm_primary_paseo_agent_file "$agent_id") || return 1 + [ -f "$agent_file" ] && [ ! -L "$agent_file" ] || return 1 + value=$(jq -r '.persistence.sessionId // .runtimeInfo.sessionId // empty' "$agent_file" 2>/dev/null) || return 1 + [ -z "$value" ] || fm_primary_atom_valid "$value" || return 1 + printf '%s\n' "$value" +} + +fm_primary_owner_descriptor_value() { + local state=$1 key=$2 descriptor + descriptor="$state/.lock.owner" + [ -f "$descriptor" ] && [ ! -L "$descriptor" ] || return 1 + sed -n "s/^${key}=//p" "$descriptor" | tail -1 +} + +fm_primary_mark_reacquired_receipts() { + local external_id=$1 owner_pid=$2 fingerprint receipt receipt_external receipt_home receipt_state now + [ -d "$FM_PRIMARY_LIB_DATA/primary-session-handoffs" ] || return 0 + fingerprint=$(fm_primary_home_fingerprint) || return 1 + now=$(fm_primary_now_utc) || return 1 + for receipt in "$FM_PRIMARY_LIB_DATA"/primary-session-handoffs/*.receipt; do + [ -e "$receipt" ] || continue + [ -f "$receipt" ] && [ ! -L "$receipt" ] || continue + [ "$(fm_primary_receipt_last_value "$receipt" schema 2>/dev/null || true)" = "fm-primary-session-handoff.v1" ] || continue + receipt_external=$(fm_primary_receipt_last_value "$receipt" external_session_id 2>/dev/null || true) + receipt_home=$(fm_primary_receipt_last_value "$receipt" home_fingerprint 2>/dev/null || true) + receipt_state=$(fm_primary_receipt_last_value "$receipt" state 2>/dev/null || true) + [ "$receipt_external" = "$external_id" ] || continue + [ "$receipt_home" = "$fingerprint" ] || continue + [ "$receipt_state" = "restore-requested" ] || continue + { + printf 'reacquired_at=%s\n' "$now" + printf 'restored_owner_pid=%s\n' "$owner_pid" + printf 'state=restored\n' + } >> "$receipt" || return 1 + done +} + +fm_primary_owner_publish() { + local state=$1 owner_pid=$2 descriptor tmp harness manager external_id provider_session recorded_at + local agent_file state_provider owner_identity owner_identity_hash + descriptor="$state/.lock.owner" + harness=$(fm_harness_pid_kind "$owner_pid") || return 1 + owner_identity=$(fm_pid_identity "$owner_pid") || return 1 + owner_identity_hash=$(printf '%s' "$owner_identity" | fm_primary_sha256_text) || return 1 + manager=unmanaged + # fm-lock proves that its caller descends from owner_pid before publishing. + # Prefer the inherited launch identity because macOS does not reliably expose + # another process's environment through ps, then retain /proc as a fallback. + external_id=${PASEO_AGENT_ID:-} + [ -n "$external_id" ] \ + || external_id=$(fm_process_env_value "$owner_pid" PASEO_AGENT_ID 2>/dev/null || true) + provider_session= + if [ -n "$external_id" ]; then + fm_primary_atom_valid "$external_id" || return 1 + manager=paseo + agent_file=$(fm_primary_paseo_agent_file "$external_id" 2>/dev/null || true) + if [ -n "$agent_file" ] && command -v jq >/dev/null 2>&1; then + state_provider=$(jq -r '.provider // empty' "$agent_file" 2>/dev/null) || return 1 + [ "$state_provider" = "$harness" ] || return 1 + fi + provider_session=$(fm_primary_provider_session_from_paseo_state "$external_id" 2>/dev/null || true) + fi + recorded_at=$(fm_primary_now_utc) || return 1 + umask 077 + tmp=$(mktemp "$state/.lock.owner.write.XXXXXX" 2>/dev/null) || return 1 + if ! { + printf 'schema=fm-primary-lock-owner.v1\n' + printf 'owner_pid=%s\n' "$owner_pid" + printf 'owner_identity_sha256=%s\n' "$owner_identity_hash" + printf 'harness=%s\n' "$harness" + printf 'manager=%s\n' "$manager" + printf 'external_session_id=%s\n' "$external_id" + printf 'provider_session_id=%s\n' "$provider_session" + printf 'recorded_at=%s\n' "$recorded_at" + } > "$tmp"; then + rm -f "$tmp" + return 1 + fi + if ! mv -f "$tmp" "$descriptor" 2>/dev/null; then + rm -f "$tmp" + return 1 + fi + [ -f "$descriptor" ] && [ ! -L "$descriptor" ] || return 1 + [ "$(fm_primary_owner_descriptor_value "$state" schema 2>/dev/null || true)" = "fm-primary-lock-owner.v1" ] || return 1 + [ "$(fm_primary_owner_descriptor_value "$state" owner_pid 2>/dev/null || true)" = "$owner_pid" ] || return 1 + [ "$(fm_primary_owner_descriptor_value "$state" owner_identity_sha256 2>/dev/null || true)" = "$owner_identity_hash" ] || return 1 + [ "$(fm_primary_owner_descriptor_value "$state" harness 2>/dev/null || true)" = "$harness" ] || return 1 + [ "$(fm_primary_owner_descriptor_value "$state" manager 2>/dev/null || true)" = "$manager" ] || return 1 + [ "$(fm_primary_owner_descriptor_value "$state" external_session_id 2>/dev/null || true)" = "$external_id" ] || return 1 + if [ "$manager" = paseo ]; then + fm_primary_mark_reacquired_receipts "$external_id" "$owner_pid" || return 1 + fi + return 0 +} diff --git a/bin/fm-primary-session.sh b/bin/fm-primary-session.sh new file mode 100755 index 0000000000..5165868ce3 --- /dev/null +++ b/bin/fm-primary-session.sh @@ -0,0 +1,704 @@ +#!/usr/bin/env bash +# Safely inspect, transfer, and restore Firstmate primary sessions. +# +# Usage: +# fm-primary-session.sh scan [--json] +# Read-only fleet-wide scan of local Paseo sessions with pending +# permissions or another structured captain-action marker. +# +# fm-primary-session.sh takeover +# Prove that the exact local Paseo agent owns this home's live numeric +# session lock, classify it from structured Paseo and quota evidence, +# refuse every unsafe state, soft-archive the idle recoverable provider, +# verify the captured owner process tree is gone, publish an append-only +# privacy-safe receipt, acquire the unchanged stale numeric lock through +# bin/fm-lock.sh, and run the ordinary session-start digest. +# +# fm-primary-session.sh restore +# Refuse while any recorded lock pid is alive, mark the receipt as a +# restore request under the lock-acquisition mutex, and reload the exact +# archived Paseo agent. +# The restored Claude or Codex provider's tracked native session-start +# hook must then run bin/fm-session-start.sh before any Firstmate +# mutation. +# Successful reacquisition appends state=restored to the receipt. +# +# Supported recoverable source sessions in v1 are local Paseo-hosted Claude +# and Codex primaries whose provider reports session persistence. +# OpenCode, Pi, pi-signed, Grok, Kimi, Codex Desktop, remote Paseo hosts, and +# unmanaged terminal sessions are classified as unsupported rather than +# approximated. +# Task runtime backends are orthogonal: tmux, Herdr, Zellij, Orca, and cmux +# metadata and endpoints are never touched by this command. +# +# Safety: +# - The numeric state/.lock remains authoritative. +# - No command unlinks or overwrites a live owner's lock. +# - takeover never passes Paseo's --force flag. +# - takeover and restore serialize with ordinary acquisition through +# state/.lock.acquire. +# - receipts contain ids, classifications, timestamps, and hashes only. +# They never contain credentials, permission descriptions, or prompts. +# - scan performs no writes and omits prompt/title/permission prose. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +LOCK="$STATE/.lock" +CLAIM_LOCK="$STATE/.lock.acquire" +HANDOFF_DIR="$DATA/primary-session-handoffs" +FM_PRIMARY_WEDGE_SECS=${FM_PRIMARY_WEDGE_SECS:-600} +FM_PRIMARY_OWNER_EXIT_TIMEOUT=${FM_PRIMARY_OWNER_EXIT_TIMEOUT:-15} +FM_PRIMARY_RELOAD_TIMEOUT=${FM_PRIMARY_RELOAD_TIMEOUT:-10} + +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +# shellcheck source=bin/fm-primary-scope-lib.sh +. "$SCRIPT_DIR/fm-primary-scope-lib.sh" +# shellcheck source=bin/fm-session-lock-lib.sh +. "$SCRIPT_DIR/fm-session-lock-lib.sh" +# shellcheck source=bin/fm-primary-session-lib.sh +. "$SCRIPT_DIR/fm-primary-session-lib.sh" + +die() { + printf 'primary-session: %s\n' "$*" >&2 + exit 1 +} + +usage() { + sed -n \ + -e '2,/^set -u$/s/^# //p' \ + -e '2,/^set -u$/s/^#$//p' \ + "$0" +} + +require_read_tools() { + command -v jq >/dev/null 2>&1 || die "jq is required" + command -v paseo >/dev/null 2>&1 || die "paseo is required" +} + +require_takeover_tools() { + require_read_tools + command -v quota-axi >/dev/null 2>&1 || die "quota-axi is required for deterministic idle versus rate-limited classification" +} + +primary_scope_required() { + fm_is_gate_agent "$FM_ROOT" && die "refusing inside a no-mistakes gate agent" + fm_primary_scope_matches "$FM_ROOT" "$STATE" \ + || die "refusing outside a genuine Firstmate primary home" +} + +paseo_inspect() { + paseo agent inspect "$1" --json +} + +paseo_agent_record() { + local agent_file + agent_file=$(fm_primary_paseo_agent_file "$1") || return 1 + [ -f "$agent_file" ] && [ ! -L "$agent_file" ] || return 1 + jq -e '.' "$agent_file" +} + +iso_epoch() { + local raw=$1 normalized + normalized=$(sed -E 's/\.[0-9]+Z$/Z/' <<< "$raw") + if date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$normalized" '+%s' >/dev/null 2>&1; then + date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$normalized" '+%s' 2>/dev/null + else + date -u -d "$normalized" '+%s' 2>/dev/null + fi +} + +quota_classification() { + local provider=$1 quota provider_count state_ok blocked + quota=$(quota-axi --provider "$provider" --json 2>/dev/null) || return 1 + provider_count=$(jq --arg provider "$provider" '[.providers[]? | select(.provider == $provider)] | length' <<< "$quota") || return 1 + [ "$provider_count" = 1 ] || return 1 + state_ok=$(jq -r --arg provider "$provider" ' + [.providers[]? | select(.provider == $provider)][0] + | (.state.status == "fresh" and .state.stale == false) + ' <<< "$quota") || return 1 + [ "$state_ok" = true ] || return 1 + blocked=$(jq -r --arg provider "$provider" ' + [.providers[]? | select(.provider == $provider)][0] + | any(.windows[]?; + ((.kind == "session") or (.kind == "weekly")) + and ((.percentRemaining | numbers) <= 0)) + ' <<< "$quota") || return 1 + if [ "$blocked" = true ]; then + printf 'rate-limited\n' + else + printf 'available\n' + fi +} + +target_has_attached_children() { + local target=$1 list row child_id child + list=$(paseo agent ls --global --json 2>/dev/null) || return 2 + jq -e 'type == "array" and all(.[]; (.id | type) == "string")' <<< "$list" >/dev/null || return 2 + while IFS= read -r row; do + child_id=$(jq -r '.id' <<< "$row") || return 2 + [ "$child_id" = "$target" ] && continue + child=$(paseo_inspect "$child_id" 2>/dev/null) || return 2 + jq -e '.Id and (.Archived | type == "boolean")' <<< "$child" >/dev/null || return 2 + if [ "$(jq -r '.Archived' <<< "$child")" = false ] \ + && [ "$(jq -r '.ParentAgentId // empty' <<< "$child")" = "$target" ]; then + return 0 + fi + done < <(jq -c '.[]' <<< "$list") + return 1 +} + +classify_target() { + local owner_pid=$1 agent_id=$2 owner_harness=$3 inspect record provider status record_status archived + local persistence pending parent requires_attention attention_reason last_user last_activity unresolved=0 + local quota_state now user_epoch activity_epoch age children_rc target_cwd resolved_target_cwd resolved_root + inspect=$(paseo_inspect "$agent_id" 2>/dev/null) || { + printf 'unknown|Paseo inspection failed\n' + return 0 + } + record=$(paseo_agent_record "$agent_id" 2>/dev/null) || { + printf 'unknown|local Paseo state is missing, symlinked, or unreadable\n' + return 0 + } + if ! jq -e ' + (.Id | type) == "string" + and (.Provider | type) == "string" + and (.Status | type) == "string" + and (.Archived | type) == "boolean" + and (.Cwd | type) == "string" + and (.Capabilities.Persistence | type) == "boolean" + and (.PendingPermissions | type) == "array" + ' <<< "$inspect" >/dev/null; then + printf 'unknown|Paseo inspection schema is incomplete\n' + return 0 + fi + [ "$(jq -r '.Id' <<< "$inspect")" = "$agent_id" ] || { + printf 'unknown|Paseo resolved a different agent id\n' + return 0 + } + [ "$(jq -r '.id // empty' <<< "$record")" = "$agent_id" ] || { + printf 'unknown|persisted Paseo agent id mismatch\n' + return 0 + } + provider=$(jq -r '.Provider' <<< "$inspect") + status=$(jq -r '.Status' <<< "$inspect") + record_status=$(jq -r '.lastStatus // empty' <<< "$record") + archived=$(jq -r '.Archived' <<< "$inspect") + persistence=$(jq -r '.Capabilities.Persistence' <<< "$inspect") + [ "$archived" = false ] || { + printf 'unknown|target Paseo session is already archived\n' + return 0 + } + [ "$(jq -r '.archivedAt == null' <<< "$record")" = true ] || { + printf 'unknown|persisted Paseo archive state disagrees with the daemon\n' + return 0 + } + [ "$provider" = "$owner_harness" ] || { + printf 'unknown|Paseo provider does not match the verified lock-owner harness\n' + return 0 + } + case "$owner_harness" in + claude|codex) ;; + *) + printf 'unsupported|only local Paseo-hosted Claude and Codex primary sessions are recoverable in v1\n' + return 0 + ;; + esac + [ "$persistence" = true ] || { + printf 'unsupported|Paseo reports no persistent provider session to restore\n' + return 0 + } + [ "$record_status" = "$status" ] || { + printf 'unknown|persisted and live Paseo lifecycle states disagree\n' + return 0 + } + [ "$(jq -r '.provider // empty' <<< "$record")" = "$provider" ] || { + printf 'unknown|persisted and live Paseo providers disagree\n' + return 0 + } + target_cwd=$(jq -r '.Cwd' <<< "$inspect") + resolved_target_cwd=$(cd "$target_cwd" 2>/dev/null && pwd -P) || { + printf 'unknown|Paseo target working directory cannot be resolved\n' + return 0 + } + resolved_root=$(cd "$FM_ROOT" 2>/dev/null && pwd -P) || { + printf 'unknown|Firstmate root cannot be resolved\n' + return 0 + } + [ "$resolved_target_cwd" = "$resolved_root" ] \ + && [ "$(jq -r '.cwd // empty' <<< "$record")" = "$target_cwd" ] || { + printf 'unknown|Paseo target is not bound to this Firstmate root\n' + return 0 + } + parent=$(jq -r '.ParentAgentId // empty' <<< "$inspect") + [ -z "$parent" ] || { + printf 'unsupported|target primary is itself attached to another Paseo agent\n' + return 0 + } + if target_has_attached_children "$agent_id"; then + printf 'waiting-on-captain|target has attached child agents that archive could cascade\n' + return 0 + else + children_rc=$? + fi + [ "$children_rc" -eq 1 ] || { + printf 'unknown|attached-child inventory could not be classified\n' + return 0 + } + pending=$(jq '.PendingPermissions | length' <<< "$inspect") + requires_attention=$(jq -r '.requiresAttention // false' <<< "$record") + attention_reason=$(jq -r '.attentionReason // empty' <<< "$record") + if [ "$pending" -gt 0 ] || [ "$attention_reason" = permission ]; then + printf 'waiting-on-captain|target has pending permission or captain action\n' + return 0 + fi + last_user=$(jq -r '.lastUserMessageAt // empty' <<< "$record") + last_activity=$(jq -r '.lastActivityAt // empty' <<< "$record") + if [ -n "$last_user" ]; then + user_epoch=$(iso_epoch "$last_user" 2>/dev/null) || { + printf 'unknown|provider has an unparseable structured input timestamp\n' + return 0 + } + if [ -z "$last_activity" ]; then + unresolved=1 + else + activity_epoch=$(iso_epoch "$last_activity" 2>/dev/null) || { + printf 'unknown|provider has an unparseable structured activity timestamp\n' + return 0 + } + [ "$user_epoch" -le "$activity_epoch" ] || unresolved=1 + fi + fi + [ "$unresolved" -eq 0 ] || { + printf 'waiting-on-captain|target owns unresolved structured input\n' + return 0 + } + quota_state=$(quota_classification "$provider" 2>/dev/null) || { + printf 'unknown|fresh provider quota evidence is unavailable\n' + return 0 + } + if [ "$requires_attention" = true ]; then + if [ "$attention_reason" = error ] && [ "$status" = idle ] && [ "$quota_state" = rate-limited ]; then + printf 'paused-rate-limited|idle provider has a structured error plus exhausted fresh quota window\n' + else + printf 'waiting-on-captain|target has an unresolved structured attention marker\n' + fi + return 0 + fi + case "$status" in + idle) + if [ "$quota_state" = rate-limited ]; then + printf 'paused-rate-limited|idle provider has an exhausted fresh quota window\n' + else + printf 'idle|provider is structurally idle with no pending input, action, permission, or child\n' + fi + ;; + running) + [ -n "${activity_epoch:-}" ] \ + || activity_epoch=$(iso_epoch "$last_activity" 2>/dev/null) || { + printf 'unknown|running provider has no parseable activity timestamp\n' + return 0 + } + now=$(date +%s) + age=$(( now - activity_epoch )) + if [ "$age" -ge "$FM_PRIMARY_WEDGE_SECS" ]; then + printf 'wedged|provider is still running with no structured activity inside the wedge bound\n' + else + printf 'busy|provider has a live running turn\n' + fi + ;; + *) + printf 'unknown|provider lifecycle is neither safely idle nor a classifiable running turn\n' + ;; + esac +} + +prove_lock_owner() { + local agent_id=$1 owner_pid=$2 owner_harness=$3 process_agent descriptor descriptor_pid descriptor_harness + local descriptor_manager descriptor_agent descriptor_identity current_identity current_identity_hash + local provider_session list listed_id listed_session matches=0 + process_agent=$(fm_process_env_value "$owner_pid" PASEO_AGENT_ID 2>/dev/null || true) + descriptor="$STATE/.lock.owner" + if [ -e "$descriptor" ] || [ -L "$descriptor" ]; then + [ -f "$descriptor" ] && [ ! -L "$descriptor" ] \ + || die "owner mismatch: state/.lock.owner is not a regular file" + [ "$(fm_primary_owner_descriptor_value "$STATE" schema 2>/dev/null || true)" = "fm-primary-lock-owner.v1" ] \ + || die "owner mismatch: state/.lock.owner has an unsupported schema" + descriptor_pid=$(fm_primary_owner_descriptor_value "$STATE" owner_pid 2>/dev/null || true) + descriptor_identity=$(fm_primary_owner_descriptor_value "$STATE" owner_identity_sha256 2>/dev/null || true) + descriptor_harness=$(fm_primary_owner_descriptor_value "$STATE" harness 2>/dev/null || true) + descriptor_manager=$(fm_primary_owner_descriptor_value "$STATE" manager 2>/dev/null || true) + descriptor_agent=$(fm_primary_owner_descriptor_value "$STATE" external_session_id 2>/dev/null || true) + current_identity=$(fm_pid_identity "$owner_pid") \ + || die "owner mismatch: the live lock-owner process identity is unreadable" + current_identity_hash=$(printf '%s' "$current_identity" | fm_primary_sha256_text) \ + || die "owner mismatch: the live lock-owner process identity cannot be hashed" + [ "$descriptor_pid" = "$owner_pid" ] \ + && [ "$descriptor_identity" = "$current_identity_hash" ] \ + && [ "$descriptor_harness" = "$owner_harness" ] \ + && [ "$descriptor_manager" = paseo ] \ + && [ "$descriptor_agent" = "$agent_id" ] \ + || die "owner mismatch: state/.lock.owner does not bind the requested live Paseo owner" + return 0 + fi + [ "$process_agent" = "$agent_id" ] && return 0 + provider_session=$(fm_primary_provider_session_from_paseo_state "$agent_id" 2>/dev/null || true) + fm_primary_atom_valid "$provider_session" \ + || die "owner mismatch: no descriptor or provider-session proof binds the requested Paseo agent" + fm_process_command_has_atom "$owner_pid" "$provider_session" \ + || die "owner mismatch: the lock owner command does not contain the requested provider session" + list=$(paseo agent ls --global --json 2>/dev/null) \ + || die "owner mismatch: visible Paseo sessions could not be checked for provider-session uniqueness" + jq -e 'type == "array"' <<< "$list" >/dev/null \ + || die "owner mismatch: visible Paseo session data is not an array" + while IFS= read -r listed_id; do + fm_primary_atom_valid "$listed_id" \ + || die "owner mismatch: a visible Paseo session id is malformed" + listed_session=$(fm_primary_provider_session_from_paseo_state "$listed_id" 2>/dev/null || true) + [ "$listed_session" = "$provider_session" ] || continue + matches=$(( matches + 1 )) + [ "$listed_id" = "$agent_id" ] \ + || die "owner mismatch: provider session is shared by another visible Paseo agent" + done < <(jq -r '.[] | .id // .Id // empty' <<< "$list") + [ "$matches" -eq 1 ] \ + || die "owner mismatch: provider-session proof is not unique among visible Paseo agents" +} + +process_tree_pids() { + local root_pid=$1 + ps -axo pid=,ppid= 2>/dev/null | awk -v root="$root_pid" ' + { + parent[$1] = $2 + } + END { + selected[root] = 1 + changed = 1 + while (changed) { + changed = 0 + for (pid in parent) { + if (!selected[pid] && selected[parent[pid]]) { + selected[pid] = 1 + changed = 1 + } + } + } + for (pid in selected) { + if (selected[pid]) { + print pid + } + } + } + ' | sort -n +} + +wait_tree_gone() { + local pids=$1 deadline now pid any stat + FM_PRIMARY_REMAINING_PIDS= + deadline=$(( $(date +%s) + FM_PRIMARY_OWNER_EXIT_TIMEOUT )) + while :; do + any=0 + FM_PRIMARY_REMAINING_PIDS= + while IFS= read -r pid; do + [ -n "$pid" ] || continue + if fm_process_pid_running "$pid"; then + any=1 + stat=$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ') + FM_PRIMARY_REMAINING_PIDS="${FM_PRIMARY_REMAINING_PIDS}${FM_PRIMARY_REMAINING_PIDS:+,}${pid}:${stat:-unreadable}" + fi + done <<< "$pids" + [ "$any" -eq 0 ] && return 0 + now=$(date +%s) + [ "$now" -lt "$deadline" ] || return 1 + sleep 0.1 + done +} + +receipt_prepare() { + local agent_id=$1 owner_pid=$2 owner_harness=$3 provider=$4 classification=$5 + local provider_session fingerprint identity identity_hash receipt_stamp draft receipt_id + mkdir -p "$HANDOFF_DIR" || return 1 + [ -d "$HANDOFF_DIR" ] && [ ! -L "$HANDOFF_DIR" ] || return 1 + fingerprint=$(fm_primary_home_fingerprint) || return 1 + identity=$(fm_pid_identity "$owner_pid") || return 1 + identity_hash=$(printf '%s' "$identity" | fm_primary_sha256_text) || return 1 + provider_session=$(fm_primary_provider_session_from_paseo_state "$agent_id" 2>/dev/null || true) + receipt_stamp=$(date -u '+%Y%m%dT%H%M%SZ') + umask 077 + draft=$(mktemp "$HANDOFF_DIR/.${receipt_stamp}-${agent_id}.XXXXXX") || return 1 + receipt_id=$(basename "$draft") + receipt_id=${receipt_id#.} + if ! { + printf 'schema=fm-primary-session-handoff.v1\n' + printf 'receipt_id=%s\n' "$receipt_id" + printf 'home_fingerprint=%s\n' "$fingerprint" + printf 'manager=paseo\n' + printf 'external_session_id=%s\n' "$agent_id" + printf 'provider=%s\n' "$provider" + printf 'harness=%s\n' "$owner_harness" + printf 'provider_session_id=%s\n' "$provider_session" + printf 'old_owner_pid=%s\n' "$owner_pid" + printf 'old_owner_identity_sha256=%s\n' "$identity_hash" + printf 'classification=%s\n' "$classification" + printf 'prepared_at=%s\n' "$(fm_primary_now_utc)" + printf 'state=preparing\n' + } > "$draft"; then + rm -f "$draft" + return 1 + fi + printf '%s\n' "$draft" +} + +receipt_publish() { + local draft=$1 state=$2 at_key=$3 final receipt_id now + now=$(fm_primary_now_utc) || return 1 + fm_primary_receipt_append_state "$draft" "$state" "$at_key" "$now" || return 1 + receipt_id=$(fm_primary_receipt_last_value "$draft" receipt_id) || return 1 + final="$HANDOFF_DIR/$receipt_id.receipt" + [ ! -e "$final" ] && [ ! -L "$final" ] || return 1 + mv "$draft" "$final" || return 1 + printf '%s\n' "$final" +} + +acquire_claim_lock() { + # shellcheck source=bin/fm-wake-lib.sh + . "$SCRIPT_DIR/fm-wake-lib.sh" + fm_lock_acquire_wait "$CLAIM_LOCK" + CLAIM_LOCK_HELD=1 +} + +release_claim_lock() { + if [ "${CLAIM_LOCK_HELD:-0}" -eq 1 ]; then + fm_lock_release "$CLAIM_LOCK" + CLAIM_LOCK_HELD=0 + fi +} + +takeover() { + local agent_id=$1 new_owner_pid owner_pid owner_harness classification reason inspect provider + local tree draft archive_out post receipt new_lock_pid now rc=0 + fm_primary_atom_valid "$agent_id" || die "invalid Paseo agent id" + require_takeover_tools + primary_scope_required + [ -d "$STATE" ] || die "state directory is absent" + new_owner_pid=$(fm_harness_ancestry_pid) || die "cannot resolve this new primary's verified harness ancestry" + CLAIM_LOCK_HELD=0 + trap release_claim_lock EXIT + trap 'exit 1' HUP INT TERM + acquire_claim_lock + [ -f "$LOCK" ] && [ ! -L "$LOCK" ] || die "takeover requires a regular existing session lock" + owner_pid=$(cat "$LOCK" 2>/dev/null) || die "session lock is unreadable" + case "$owner_pid" in ''|*[!0-9]*|1) die "session lock owner is malformed" ;; esac + [ "$owner_pid" != "$new_owner_pid" ] || die "this primary already owns the session lock" + fm_harness_pid_alive "$owner_pid" || die "requested takeover target is not a provably live verified lock owner" + owner_harness=$(fm_harness_pid_kind "$owner_pid") || die "cannot classify the live lock-owner harness" + prove_lock_owner "$agent_id" "$owner_pid" "$owner_harness" + IFS='|' read -r classification reason <<< "$(classify_target "$owner_pid" "$agent_id" "$owner_harness")" + case "$classification" in + idle|paused-rate-limited) ;; + *) die "takeover refused: $classification: $reason" ;; + esac + inspect=$(paseo_inspect "$agent_id") || die "Paseo inspection failed before suspension" + provider=$(jq -r '.Provider' <<< "$inspect") + tree=$(process_tree_pids "$owner_pid") || die "could not capture the lock-owner process tree" + grep -qx "$owner_pid" <<< "$tree" || die "captured process tree omitted its owner" + grep -qx "$new_owner_pid" <<< "$tree" \ + && die "takeover refused: the successor primary is inside the target provider process tree" + draft=$(receipt_prepare "$agent_id" "$owner_pid" "$owner_harness" "$provider" "$classification") \ + || die "could not prepare a durable handoff receipt before suspension" + if ! archive_out=$(paseo agent archive "$agent_id" --json 2>&1); then + rm -f "$draft" + die "Paseo soft archive failed: $archive_out" + fi + post=$(paseo_inspect "$agent_id" 2>/dev/null) || { + receipt=$(receipt_publish "$draft" suspend-incomplete suspend_incomplete_at) || true + die "Paseo archive returned success but the archived session could not be verified" + } + if [ "$(jq -r '.Archived' <<< "$post")" != true ]; then + receipt=$(receipt_publish "$draft" suspend-incomplete suspend_incomplete_at) || true + die "Paseo archive returned success but the target is not archived" + fi + if ! wait_tree_gone "$tree"; then + receipt=$(receipt_publish "$draft" suspend-incomplete suspend_incomplete_at) \ + || die "owner process tree remained alive and the incomplete handoff receipt could not be published" + die "owner process tree remained alive after soft archive ($FM_PRIMARY_REMAINING_PIDS); lock was left untouched; receipt: $receipt" + fi + receipt=$(receipt_publish "$draft" suspended suspended_at) \ + || die "provider was suspended but the durable handoff receipt could not be published" + release_claim_lock + if ! "$SCRIPT_DIR/fm-lock.sh"; then + die "provider was suspended, but this primary lost or failed canonical stale-lock acquisition; receipt: $receipt" + fi + new_lock_pid=$(cat "$LOCK" 2>/dev/null || true) + [ "$new_lock_pid" = "$new_owner_pid" ] \ + || die "canonical acquisition returned without the expected new owner; receipt: $receipt" + now=$(fm_primary_now_utc) + { + printf 'taken_over_at=%s\n' "$now" + printf 'successor_owner_pid=%s\n' "$new_owner_pid" + printf 'state=active-successor\n' + } >> "$receipt" || die "new primary owns the lock but receipt finalization failed: $receipt" + printf 'primary-session: takeover complete: %s -> harness pid %s\n' "$agent_id" "$new_owner_pid" + printf 'primary-session: receipt: %s\n' "$receipt" + "$SCRIPT_DIR/fm-session-start.sh" || rc=$? + [ "$rc" -eq 0 ] || die "lock transferred, but ordinary session start failed with exit $rc" +} + +resolve_receipt() { + local receipt_id=$1 candidate + fm_primary_atom_valid "$receipt_id" || return 1 + candidate="$HANDOFF_DIR/$receipt_id" + case "$candidate" in *.receipt) ;; *) candidate="${candidate}.receipt" ;; esac + [ -f "$candidate" ] && [ ! -L "$candidate" ] || return 1 + printf '%s\n' "$candidate" +} + +restore() { + local receipt_id=$1 receipt fingerprint receipt_home state agent_id manager harness provider inspect out + local deadline post now lock_pid + require_read_tools + primary_scope_required + receipt=$(resolve_receipt "$receipt_id") || die "receipt not found or invalid: $receipt_id" + [ "$(fm_primary_receipt_last_value "$receipt" schema 2>/dev/null || true)" = "fm-primary-session-handoff.v1" ] \ + || die "receipt has an unsupported schema" + fingerprint=$(fm_primary_home_fingerprint) || die "cannot fingerprint this Firstmate home" + receipt_home=$(fm_primary_receipt_last_value "$receipt" home_fingerprint 2>/dev/null || true) + [ "$receipt_home" = "$fingerprint" ] || die "receipt belongs to a different Firstmate home" + state=$(fm_primary_receipt_last_value "$receipt" state 2>/dev/null || true) + case "$state" in + active-successor|suspended|suspend-incomplete|restore-failed) ;; + *) die "receipt state '$state' is not eligible for restore" ;; + esac + manager=$(fm_primary_receipt_last_value "$receipt" manager 2>/dev/null || true) + harness=$(fm_primary_receipt_last_value "$receipt" harness 2>/dev/null || true) + provider=$(fm_primary_receipt_last_value "$receipt" provider 2>/dev/null || true) + agent_id=$(fm_primary_receipt_last_value "$receipt" external_session_id 2>/dev/null || true) + [ "$manager" = paseo ] || die "receipt manager '$manager' is unsupported" + case "$harness:$provider" in + claude:claude|codex:codex) ;; + *) die "receipt provider/harness '$provider/$harness' is unsupported for enforced restoration" ;; + esac + fm_primary_atom_valid "$agent_id" || die "receipt external session id is invalid" + CLAIM_LOCK_HELD=0 + trap release_claim_lock EXIT + trap 'exit 1' HUP INT TERM + acquire_claim_lock + if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then + [ -f "$LOCK" ] && [ ! -L "$LOCK" ] || die "session lock is not a regular file" + lock_pid=$(cat "$LOCK" 2>/dev/null) || die "session lock is unreadable" + case "$lock_pid" in ''|*[!0-9]*|1) die "session lock owner is malformed" ;; esac + if kill -0 "$lock_pid" 2>/dev/null; then + die "restore refused: live pid $lock_pid still owns or ambiguously occupies the session lock" + fi + fi + inspect=$(paseo_inspect "$agent_id" 2>/dev/null) || die "cannot inspect archived Paseo agent $agent_id" + [ "$(jq -r '.Id' <<< "$inspect")" = "$agent_id" ] || die "Paseo resolved a different restore target" + [ "$(jq -r '.Archived' <<< "$inspect")" = true ] || die "restore target is not archived" + now=$(fm_primary_now_utc) + fm_primary_receipt_append_state "$receipt" restore-requested restore_requested_at "$now" \ + || die "cannot record the restore request" + if ! out=$(paseo agent reload "$agent_id" --json 2>&1); then + now=$(fm_primary_now_utc) + fm_primary_receipt_append_state "$receipt" restore-failed restore_failed_at "$now" || true + die "Paseo reload failed: $out" + fi + deadline=$(( $(date +%s) + FM_PRIMARY_RELOAD_TIMEOUT )) + while :; do + post=$(paseo_inspect "$agent_id" 2>/dev/null || true) + if [ -n "$post" ] && [ "$(jq -r '.Id // empty' <<< "$post" 2>/dev/null)" = "$agent_id" ] \ + && [ "$(jq -r '.Archived' <<< "$post" 2>/dev/null)" = false ]; then + break + fi + [ "$(date +%s)" -lt "$deadline" ] || { + now=$(fm_primary_now_utc) + fm_primary_receipt_append_state "$receipt" restore-failed restore_failed_at "$now" || true + die "Paseo reload returned success but the session did not become visible" + } + sleep 0.1 + done + printf 'reload_observed_at=%s\n' "$(fm_primary_now_utc)" >> "$receipt" \ + || die "reload succeeded but receipt observation could not be recorded" + release_claim_lock + printf 'primary-session: restore requested for %s\n' "$agent_id" + printf 'primary-session: restored provider must complete the native session-start nudge and acquire the normal Firstmate lock before mutation\n' + printf 'primary-session: receipt remains state=restore-requested until that lock acquisition is recorded: %s\n' "$receipt" +} + +scan() { + local format=${1:-table} list rows row agent_id inspect record pending attention reason parent provider status item + require_read_tools + list=$(paseo agent ls --global --json 2>/dev/null) || die "could not list local Paseo sessions" + jq -e 'type == "array" and all(.[]; (.id | type) == "string")' <<< "$list" >/dev/null \ + || die "Paseo session inventory has an unsupported schema" + rows= + while IFS= read -r row; do + agent_id=$(jq -r '.id' <<< "$row") + inspect=$(paseo_inspect "$agent_id" 2>/dev/null) || die "could not inspect visible Paseo session $agent_id" + [ "$(jq -r '.Archived' <<< "$inspect")" = false ] || continue + record=$(paseo_agent_record "$agent_id" 2>/dev/null) || die "could not read structured Paseo state for $agent_id" + pending=$(jq '.PendingPermissions | length' <<< "$inspect") + attention=$(jq -r '.requiresAttention // false' <<< "$record") + reason=$(jq -r '.attentionReason // empty' <<< "$record") + if [ "$pending" -gt 0 ]; then + reason=permission + elif [ "$attention" != true ]; then + continue + fi + case "$reason" in + permission|finished|error) ;; + needs_input) reason=needs-input ;; + *) reason=attention ;; + esac + parent=$(jq -r '.ParentAgentId // empty' <<< "$inspect") + provider=$(jq -r '.Provider' <<< "$inspect") + status=$(jq -r '.Status' <<< "$inspect") + item=$(jq -cn \ + --arg id "$agent_id" \ + --arg provider "$provider" \ + --arg status "$status" \ + --arg action "$reason" \ + --argjson pending "$pending" \ + --arg parent "$parent" \ + '{external_session_id:$id,provider:$provider,status:$status,action:$action,pending_permissions:$pending,parent_agent_id:(if $parent == "" then null else $parent end)}') + rows="${rows}${item}"$'\n' + done < <(jq -c '.[]' <<< "$list") + if [ "$format" = json ]; then + printf '%s' "$rows" | jq -s '.' + return + fi + if [ -z "$rows" ]; then + printf 'primary-session: no visible Paseo sessions await permission or captain action\n' + return + fi + printf 'EXTERNAL_SESSION_ID\tPROVIDER\tSTATUS\tACTION\tPENDING_PERMISSIONS\tPARENT_AGENT_ID\n' + printf '%s' "$rows" | jq -r '[.external_session_id,.provider,.status,.action,(.pending_permissions|tostring),(.parent_agent_id // "-")] | @tsv' +} + +command=${1:-} +case "$command" in + scan) + case "${2:-}" in + '') scan table ;; + --json) scan json ;; + *) die "unknown scan option: ${2:-}" ;; + esac + ;; + takeover) + [ "$#" -eq 2 ] || die "usage: fm-primary-session.sh takeover " + takeover "$2" + ;; + restore) + [ "$#" -eq 2 ] || die "usage: fm-primary-session.sh restore " + restore "$2" + ;; + -h|--help|help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 0e518c1c8d..57efd1082e 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -11,6 +11,94 @@ # Known harness command names; extend when a new adapter is verified. FM_HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$|^pi-signed$' +# A zombie has exited and cannot own fleet state even though kill -0 still +# succeeds until its parent reaps the process table entry. +fm_process_pid_running() { + local pid=$1 stat + kill -0 "$pid" 2>/dev/null || return 1 + stat=$(ps -o stat= -p "$pid" 2>/dev/null) || return 0 + [ -n "$stat" ] || return 0 + case "$stat" in *Z*) return 1 ;; esac + return 0 +} + +# Print the canonical verified harness kind for a live process. +# This is the named form of fm_harness_pid_alive's exact decision and lets the +# primary-session handoff bind a Paseo provider to the numeric lock owner +# without re-parsing process names elsewhere. +fm_harness_pid_kind() { + local pid=$1 comm args bc candidate + fm_process_pid_running "$pid" || return 1 + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 + args=$(ps -o args= -p "$pid" 2>/dev/null || true) + bc=$(basename "$comm") + candidate=$bc + case "$comm" in + *node*|*python*) candidate=$args ;; + esac + case "$candidate" in + *claude*) echo claude ;; + *codex*) echo codex ;; + *opencode*) echo opencode ;; + *grok*) echo grok ;; + *kimi*) echo kimi ;; + pi-signed) echo pi-signed ;; + pi) echo pi ;; + *) return 1 ;; + esac +} + +# Print one atom-valued environment variable from a live process. +# Linux-compatible /proc is preferred. +# macOS falls back to ps eww and accepts only a whitespace-delimited NAME=value +# token, which is sufficient for Paseo and provider session UUIDs. +fm_process_env_value() { + local pid=$1 name=$2 proc_root out + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + case "$name" in ''|*[!A-Z0-9_]*) return 1 ;; esac + proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} + if [ -r "$proc_root/$pid/environ" ]; then + tr '\000' '\n' < "$proc_root/$pid/environ" 2>/dev/null \ + | sed -n "s/^${name}=//p" \ + | head -1 + return "${PIPESTATUS[0]}" + fi + out=$(ps eww -p "$pid" -o command= 2>/dev/null) || return 1 + printf '%s\n' "$out" | awk -v prefix="${name}=" ' + { + for (i = 1; i <= NF; i++) { + if (index($i, prefix) == 1) { + print substr($i, length(prefix) + 1) + exit + } + } + } + ' +} + +# Return success only when a live process command contains an exact atom. +# This is a compatibility proof for a provider session launched before +# state/.lock.owner existed; substring matches are intentionally rejected. +fm_process_command_has_atom() { + local pid=$1 atom=$2 out + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + [ -n "$atom" ] || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + out=$(ps -o args= -p "$pid" 2>/dev/null) || return 1 + awk -v atom="$atom" ' + { + for (i = 1; i <= NF; i++) { + if ($i == atom) { + found = 1 + } + } + } + END { + exit(found ? 0 : 1) + } + ' <<< "$out" +} + # Walk the current process ancestry (up to 16 hops) and print a harness pid. # For every harness except Claude, the first match wins (innermost pid), which # is where e.g. Pi's shared signed-wrapper ancestry actually holds the session: @@ -66,19 +154,7 @@ fm_harness_ancestry_pid() { # True if $1 is a live process that looks like a verified harness. fm_harness_pid_alive() { - local pid=$1 comm args - kill -0 "$pid" 2>/dev/null || return 1 - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - if printf '%s' "$(basename "$comm")" | grep -qE "$FM_HARNESS_RE"; then - return 0 - fi - case "$comm" in - *node*|*python*) - args=$(ps -o args= -p "$pid" 2>/dev/null) - printf '%s' "$args" | grep -qE "$FM_HARNESS_RE" - ;; - *) return 1 ;; - esac + fm_harness_pid_kind "$1" >/dev/null } # True when state dir $1 holds a session lock whose pid is the harness ancestor diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 255c1cdc31..f9559e54f8 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -131,7 +131,7 @@ family_for_basename() { fm-test-run.test.sh|fm-test-isolation-proof.test.sh) printf '%s\n' pure-contract-unit ;; - fm-daemon.test.sh|fm-guard-stale-banner.test.sh|fm-pi-watch-extension.test.sh|\ + fm-claude-stop-autoarm.test.sh|fm-daemon.test.sh|fm-guard-stale-banner.test.sh|fm-pi-watch-extension.test.sh|\ fm-supervision-events.test.sh|fm-turnend-guard.test.sh|fm-wake-daemon-lifecycle-e2e.test.sh|\ fm-wake-queue.test.sh|fm-watch-checkpoint.test.sh|fm-watch-triage.test.sh|\ fm-watcher-lock.test.sh) @@ -150,7 +150,7 @@ family_for_basename() { printf '%s\n' secondmate ;; fm-bootstrap.test.sh|fm-fleet-sync.test.sh|fm-gate-refuse.test.sh|fm-gotmp.test.sh|\ - fm-session-start.test.sh|fm-sessionstart-nudge.test.sh|fm-tangle-guard.test.sh|\ + fm-primary-session.test.sh|fm-session-start.test.sh|fm-sessionstart-nudge.test.sh|fm-tangle-guard.test.sh|\ fm-update.test.sh) printf '%s\n' session-bootstrap ;; @@ -646,6 +646,10 @@ families_for_changed_path() { bin/fm-classify-lib.sh|bin/fm-daemon*|bin/fm-turnend-guard*|bin/fm-guard.sh) printf '%s\n' watcher-wake-lock ;; + bin/fm-session-lock-lib.sh) + printf '%s\n' session-bootstrap + printf '%s\n' watcher-wake-lock + ;; bin/fm-afk*) printf '%s\n' afk printf '%s\n' real-herdr-gated @@ -660,7 +664,7 @@ families_for_changed_path() { bin/fm-config-inherit-lib.sh|bin/fm-config-push.sh|bin/fm-shared*) printf '%s\n' secondmate ;; - bin/fm-session-start.sh|bin/fm-bootstrap.sh|bin/fm-fleet-sync.sh|\ + bin/fm-primary-session*|bin/fm-session-start.sh|bin/fm-bootstrap.sh|bin/fm-fleet-sync.sh|\ bin/fm-sessionstart-nudge.sh|bin/fm-tangle*|bin/fm-update.sh|\ bin/fm-gate-refuse*|bin/fm-lock*) printf '%s\n' session-bootstrap diff --git a/docs/configuration.md b/docs/configuration.md index c5215512b2..8432959ca9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,8 +10,8 @@ The shared orchestrator behavior lives in [`AGENTS.md`](../AGENTS.md) - edit it This section is the single owner of the top-level operational-home layout; producer script headers and their help own exact child-file fields and mutation contracts. The tracked code root contains the shared instruction, skill, documentation, workflow, and `bin/` surfaces, while each effective `FM_HOME` contains private operational directories. -`data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, learnings, backlog, briefs, and scout reports. -`state/` holds volatile runtime records such as task metadata, append-only status events, endpoint signals, watcher and wake-queue coordination, away-mode state, generated X-mode artifacts, private secondmate config-reread generations with their retry and quarantine state, and parent-owned secondmate pending-reply records under `state/pending-replies/` (`bin/fm-pending-reply-lib.sh`). +`data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, learnings, backlog, briefs, scout reports, and privacy-safe primary-session handoff receipts under `data/primary-session-handoffs/`. +`state/` holds volatile runtime records such as task metadata, the replaceable `state/.lock.owner` primary identity descriptor, append-only status events, endpoint signals, watcher and wake-queue coordination, away-mode state, generated X-mode artifacts, private secondmate config-reread generations with their retry and quarantine state, and parent-owned secondmate pending-reply records under `state/pending-replies/` (`bin/fm-pending-reply-lib.sh`). `config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the guarded exceptions in `AGENTS.md`. `bin/fm-spawn.sh` owns the base task-metadata fields it emits, while the runtime-backend section below owns backend-specific fields and selector interpretation. @@ -20,6 +20,7 @@ Wake, watcher, away-mode, and X-specific state mechanics remain with their named `bin/fm-session-start.sh`'s header is the single owner of session-start ordering, composed commands, digest contents, and the digest's startup mechanism. `docs/sessionstart-nudge.md` owns the native session-open adapter mechanics that nudge the digest command. +[`docs/primary-session-handoff.md`](primary-session-handoff.md) owns the current operator contract for transferring and restoring a recoverable primary provider while preserving the numeric lock boundary. `AGENTS.md` retains the run-once and read-once operator rules, lock-refusal safety, installation consent, and direct-report recovery boundaries because those facts apply at every session start. Ordinary dead-direct-report recovery is owned by `stuck-crewmate-recovery`, while persistent-secondmate recovery is owned by `secondmate-provisioning`. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 54b2190f6c..fbf254c2ef 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -65,6 +65,10 @@ "source": "docs/sessionstart-nudge.md", "target": "docs/verification/supervision.md" }, + { + "source": "docs/primary-session-handoff.md", + "target": "docs/verification/primary-session-handoff.md" + }, { "source": "docs/turnend-guard.md", "target": "docs/verification/supervision.md" @@ -155,6 +159,10 @@ "path": ".agents/skills/project-management/SKILL.md", "audience": "agent-runtime" }, + { + "path": ".agents/skills/primary-session-handoff/SKILL.md", + "audience": "agent-runtime" + }, { "path": ".agents/skills/quota-array-dispatch/SKILL.md", "audience": "agent-runtime" @@ -259,6 +267,10 @@ "path": "docs/orca-backend.md", "audience": "operator-current" }, + { + "path": "docs/primary-session-handoff.md", + "audience": "operator-current" + }, { "path": "docs/scripts.md", "audience": "operator-current" @@ -307,6 +319,10 @@ "path": "docs/verification/runtime-backends.md", "audience": "maintainer-verification" }, + { + "path": "docs/verification/primary-session-handoff.md", + "audience": "maintainer-verification" + }, { "path": "docs/verification/supervision.md", "audience": "maintainer-verification" diff --git a/docs/primary-session-handoff.md b/docs/primary-session-handoff.md new file mode 100644 index 0000000000..b92389704f --- /dev/null +++ b/docs/primary-session-handoff.md @@ -0,0 +1,62 @@ +# Recoverable primary-session handoff + +Firstmate can transfer primary orchestration away from an idle local Paseo-hosted Claude or Codex session without weakening the live session lock or losing the provider transcript. +The transfer soft-archives the exact recoverable provider session, proves its captured process tree has stopped, acquires the unchanged numeric lock through the ordinary stale-owner path, and runs normal session start for the successor. + +Use the command family directly or let the first mate follow the agent-only `primary-session-handoff` procedure: + +```sh +bin/fm-primary-session.sh scan +bin/fm-primary-session.sh takeover +bin/fm-primary-session.sh restore +``` + +Run `bin/fm-primary-session.sh --help` for the authoritative command mechanics, inputs, outputs, and receipt-state contract. + +## Safety boundary + +`state/.lock` remains the sole authoritative primary lock. +Successful ordinary acquisition also publishes `state/.lock.owner`, a replaceable identity descriptor that binds the numeric owner and process-identity hash to its harness and local Paseo identity when present. +The takeover command serializes against ordinary acquisition, proves the requested visible session is the live lock owner, and never unlinks or overwrites a live owner's lock. + +A pre-descriptor session can qualify only when its exact persistent provider-session id appears as a complete argument in the lock-owner command and is unique among visible Paseo agents. +If either proof is missing or ambiguous, takeover refuses. + +Before suspension, structured Paseo inspection, persisted lifecycle state, current provider quota, timestamps, parent/child relationships, pending permissions, and attention markers produce one deterministic classification. +Only `idle` and `paused-rate-limited` are eligible for transfer. +`waiting-on-captain`, `busy`, `wedged`, `unknown`, and `unsupported` refuse without provider lifecycle mutation. + +The privacy-safe receipt is stored under `data/primary-session-handoffs/`. +It contains session identifiers, provider and harness identity, classification, timestamps, a home fingerprint, and a process-identity hash, but no credentials, prompts, titles, permission descriptions, or transcript text. +Task metadata, worktrees, wake records, secondmate homes, and provider transcripts are outside the transaction and remain untouched. + +## Restoration + +Restoration is an explicit second operation after the successor primary has exited. +It refuses while any pid in the recorded lock is alive or cannot safely be treated as exited. +The command records `restore-requested` before asking Paseo to reload the archived provider and verifies that the provider becomes visible again. + +Reload is not lock authority. +The restored Claude or Codex session must receive its tracked native session-start nudge and complete `bin/fm-session-start.sh` before any Firstmate mutation. +If another primary already owns the lock, ordinary startup refuses and dual orchestration never begins. + +## Supported combinations + +| Visible primary host | Provider harness | Recoverable handoff | +| --- | --- | --- | +| Local Paseo | Claude | Supported when persistence and identity proofs are available | +| Local Paseo | Codex | Supported when persistence and identity proofs are available | +| Local Paseo | OpenCode, Pi, `pi-signed`, Grok, or Kimi | Unsupported in v1 | +| Codex Desktop or unmanaged terminal | Any | Unsupported in v1 | +| Remote Paseo host | Any | Unsupported in v1 | + +The task runtime backend is orthogonal to primary-provider ownership. +The command does not start, stop, inspect, or reconfigure tmux, Herdr, Zellij, Orca, or cmux endpoints. + +## Read-only captain-action scan + +`scan` checks all visible local Paseo sessions for pending permissions or structured captain-action markers. +Its table and JSON forms include only external session id, provider, lifecycle status, action kind, pending-permission count, and parent id. +The scan performs no writes and no provider lifecycle calls. + +Current empirical verification and the hermetic regression entry point live in [`verification/primary-session-handoff.md`](verification/primary-session-handoff.md). diff --git a/docs/scripts.md b/docs/scripts.md index 6a10d1310a..0e61d16b89 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -29,6 +29,8 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and stale watcher liveness | | `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks | | `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk and holder liveness) for fm-lock.sh and the Claude Stop auto-arm | +| `fm-primary-session.sh` | Prove, classify, transfer, restore, and scan recoverable primary-provider sessions | +| `fm-primary-session-lib.sh` | Shared primary owner-descriptor and privacy-safe handoff-receipt helpers | | `fm-claude-stop-autoarm.sh` | Claude Stop `asyncRewake` hook owning tokenless watcher continuity with single-flight exit-2 rewake (docs/watcher-continuity.md) | | `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) | | `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard | diff --git a/docs/verification/primary-session-handoff.md b/docs/verification/primary-session-handoff.md new file mode 100644 index 0000000000..a3ac90cbb3 --- /dev/null +++ b/docs/verification/primary-session-handoff.md @@ -0,0 +1,57 @@ +# Primary-session handoff verification + +Audience: maintainer verification. + +This record supports the current recoverable primary-session handoff guarantee. +Operator behavior, supported combinations, and current limits remain in [`../primary-session-handoff.md`](../primary-session-handoff.md). + +## Incident-derived contract + +The missing primitive was reproduced on 2026-07-29 with Paseo 0.1.104 and Claude Code 2.1.219. +A live but idle credit-exhausted provider still correctly owned Firstmate's numeric lock, so another primary was refused even though a recoverable soft archive could safely stop that provider. +The manual incident recovery established the shipped ordering: prove visible-session ownership and idle safety, soft-archive the exact provider, prove its process tree has exited, use ordinary stale-lock acquisition, and require the restored provider to reacquire through normal session start. + +No live provider lifecycle command is part of repository verification. +All suspend, reload, quota, permission, child-agent, and process-tree behavior is exercised through an isolated disposable Firstmate home and fake Paseo executable. + +## Deterministic regression + +Run: + +```sh +bin/fm-test-run.sh tests/fm-primary-session.test.sh +``` + +The suite covers external-session and process-identity owner mismatch, busy and pending-action refusal, unknown and wedged classification, attached-child refusal, provider suspend failure, a surviving owner pid, successful stale-lock takeover, concurrent attempts, restore refusal under a live successor, restored normal lock reacquisition, fleet-state preservation, and the privacy-safe read-only scan. + +The expected terminal lines include: + +```text +ok - primary-session: concurrent takeover attempts admit one suspension and one successor +ok - primary-session: archived provider reload remains recoverable and reacquires through normal session start +ok - primary-session: fleet-wide captain-action scan is read-only and omits prompt/title prose +``` + +Related lock, startup, and native-nudge regressions are: + +```sh +bin/fm-test-run.sh \ + tests/fm-claude-stop-autoarm.test.sh \ + tests/fm-session-start.test.sh \ + tests/fm-sessionstart-nudge.test.sh \ + tests/fm-watcher-lock.test.sh +``` + +Repository structural validation remains: + +```sh +bin/fm-doc-audience-check.sh +bin/fm-lint.sh +bin/fm-test-run.sh --lane portable-parallel-1 --jobs 4 +bin/fm-test-run.sh --lane portable-parallel-2 --jobs 4 +bin/fm-test-run.sh --lane portable-serial +``` + +The real-Herdr lifecycle lane is separate and opt-in. It is not part of this +handoff verification because the task's Herdr lifecycle safety gate was not +enabled. diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index bcc4fceefb..68ac2dda18 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -31,6 +31,7 @@ install_autoarm_scripts() { cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh" cp "$ROOT/bin/fm-session-lock-lib.sh" "$dir/bin/fm-session-lock-lib.sh" + cp "$ROOT/bin/fm-primary-session-lib.sh" "$dir/bin/fm-primary-session-lib.sh" cp "$ROOT/bin/fm-lock.sh" "$dir/bin/fm-lock.sh" chmod +x "$dir/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-lock.sh" } diff --git a/tests/fm-primary-session.test.sh b/tests/fm-primary-session.test.sh new file mode 100755 index 0000000000..ae8063de23 --- /dev/null +++ b/tests/fm-primary-session.test.sh @@ -0,0 +1,618 @@ +#!/usr/bin/env bash +# Hermetic behavior tests for the recoverable primary-session handoff. +# Every Paseo call is a fake local provider fixture. +# No real agent, daemon, provider transcript, or Firstmate home is inspected or +# mutated by this suite. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-primary-session) +LIVE_PIDS=() +AGENT_A=agent-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa +AGENT_B=agent-bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb +AGENT_C=agent-cccccccc-cccc-cccc-cccc-cccccccccccc + +record_last_value() { + local file=$1 key=$2 + sed -n "s/^${key}=//p" "$file" | tail -1 +} + +cleanup_primary_session_tests() { + local pid + for pid in "${LIVE_PIDS[@]:-}"; do + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + fm_test_cleanup +} +trap cleanup_primary_session_tests EXIT + +write_fake_paseo() { + local fakebin=$1 + cat > "$fakebin/paseo" <<'SH' +#!/usr/bin/env bash +set -u +home=${PASEO_HOME:?} +cmd1=${1:-} +cmd2=${2:-} + +inspect_agent() { + local id=$1 file archived=false status provider cwd pending parent persistence + file=$(resolve_agent_file "$id") || exit 4 + [ ! -e "$home/archived-$id" ] || archived=true + status=$(jq -r '.lastStatus' "$file") + [ "$archived" = false ] || status=closed + provider=$(jq -r '.provider' "$file") + cwd=$(jq -r '.cwd' "$file") + pending=$(jq -r '.testPending // 0' "$file") + parent=$(jq -r '.testParent // empty' "$file") + persistence=$(jq -r '.testPersistence // true' "$file") + jq -cn \ + --arg id "$id" \ + --arg provider "$provider" \ + --arg status "$status" \ + --arg cwd "$cwd" \ + --arg parent "$parent" \ + --argjson archived "$archived" \ + --argjson pending "$pending" \ + --argjson persistence "$persistence" ' + { + Id:$id, + Provider:$provider, + Status:$status, + Archived:$archived, + Cwd:$cwd, + Capabilities:{Persistence:$persistence}, + PendingPermissions:[range(0;$pending) | {id:("permission-" + tostring),tool:"fixture"}], + ParentAgentId:(if $parent == "" then null else $parent end) + } + ' +} + +resolve_agent_file() { + local id=$1 candidate match='' count=0 + if [ -f "$home/agents/$id.json" ]; then + printf '%s\n' "$home/agents/$id.json" + return 0 + fi + for candidate in "$home"/agents/*/"$id.json"; do + [ -f "$candidate" ] || continue + match=$candidate + count=$(( count + 1 )) + done + [ "$count" -eq 1 ] || return 1 + printf '%s\n' "$match" +} + +list_agents() { + local file id archived + for file in "$home"/agents/*.json "$home"/agents/*/*.json; do + [ -e "$file" ] || continue + id=$(jq -r '.id' "$file") + archived=false + [ ! -e "$home/archived-$id" ] || archived=true + [ "$archived" = false ] || continue + jq -cn --arg id "$id" '{id:$id,status:"idle",provider:"claude",cwd:"fixture"}' + done | jq -s '.' +} + +case "$cmd1 $cmd2" in + "agent inspect") + inspect_agent "${3:?}" + ;; + "agent ls") + list_agents + ;; + "agent archive") + id=${3:?} + printf 'archive %s\n' "$id" >> "$home/calls.log" + if [ -e "$home/suspend-fail" ]; then + printf 'fixture suspend failed\n' >&2 + exit 9 + fi + : > "$home/archived-$id" + if [ ! -e "$home/keep-owner-alive" ]; then + owner=$(cat "$home/owner-pid") + kill "$owner" 2>/dev/null || true + fi + jq -cn --arg id "$id" '{agentId:$id,status:"archived"}' + ;; + "agent reload") + id=${3:?} + printf 'reload %s\n' "$id" >> "$home/calls.log" + rm -f "$home/archived-$id" + if [ -e "$home/reload-spawn" ]; then + PASEO_AGENT_ID="$id" "$FM_TEST_FAKE_CLAUDE" -c ' + printf "%s\n" "$$" > "$PASEO_HOME/restored-pid" + "$FM_TEST_ROOT/bin/fm-session-start.sh" >/dev/null 2>&1 + : > "$PASEO_HOME/restored-started" + trap "exit 0" TERM INT + while :; do sleep 0.2; done + ' /dev/null 2>&1 & + printf '%s\n' "$!" > "$home/reload-launch-pid" + fi + jq -cn --arg id "$id" '{agentId:$id,status:"reloaded"}' + ;; + *) + printf 'unsupported fake paseo call: %s\n' "$*" >&2 + exit 8 + ;; +esac +SH + chmod +x "$fakebin/paseo" +} + +write_fake_quota() { + local fakebin=$1 + cat > "$fakebin/quota-axi" <<'SH' +#!/usr/bin/env bash +set -u +provider=claude +prev= +for arg in "$@"; do + [ "$prev" = --provider ] && provider=$arg + prev=$arg +done +remaining=100 +[ ! -e "${PASEO_HOME:?}/quota-blocked" ] || remaining=0 +state=fresh +[ ! -e "$PASEO_HOME/quota-unknown" ] || state=error +jq -cn \ + --arg provider "$provider" \ + --arg state "$state" \ + --argjson remaining "$remaining" ' + { + schemaVersion:1, + providers:[{ + provider:$provider, + state:{status:$state,stale:false}, + windows:[{id:"five_hour",kind:"session",percentRemaining:$remaining}] + }] + } + ' +SH + chmod +x "$fakebin/quota-axi" +} + +new_world() { + local name=$1 script + W_DIR="$TMP_ROOT/$name" + W_ROOT="$W_DIR/root" + W_PASEO="$W_DIR/paseo" + W_FAKEBIN="$W_DIR/fakebin" + mkdir -p "$W_ROOT/bin" "$W_ROOT/state" "$W_ROOT/data" "$W_PASEO/agents" "$W_FAKEBIN" + git init -q -b main "$W_ROOT" + git -C "$W_ROOT" commit -q --allow-empty -m init + : > "$W_ROOT/AGENTS.md" + for script in \ + fm-primary-session.sh \ + fm-primary-session-lib.sh \ + fm-session-lock-lib.sh \ + fm-lock.sh \ + fm-wake-lib.sh \ + fm-gate-refuse-lib.sh \ + fm-primary-scope-lib.sh; do + cp "$ROOT/bin/$script" "$W_ROOT/bin/$script" + done + chmod +x "$W_ROOT/bin/fm-primary-session.sh" "$W_ROOT/bin/fm-lock.sh" + cat > "$W_ROOT/bin/fm-session-start.sh" <<'SH' +#!/usr/bin/env bash +set -u +dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$dir/fm-lock.sh" +printf 'session-start owner=%s\n' "$(cat "${FM_HOME:?}/state/.lock")" >> "$FM_HOME/state/session-start.log" +SH + chmod +x "$W_ROOT/bin/fm-session-start.sh" + ln -s /bin/bash "$W_FAKEBIN/claude" + W_FAKE_CLAUDE="$W_FAKEBIN/claude" + write_fake_paseo "$W_FAKEBIN" + write_fake_quota "$W_FAKEBIN" +} + +write_agent() { + local id=$1 status=${2:-idle} pending=${3:-0} attention=${4:-false} reason=${5:-} + local activity=${6:-2026-07-29T07:00:00Z} parent=${7:-} + local last_user=${8:-2026-07-29T06:00:00Z} + jq -n \ + --arg id "$id" \ + --arg provider claude \ + --arg cwd "$W_ROOT" \ + --arg status "$status" \ + --arg activity "$activity" \ + --arg last_user "$last_user" \ + --arg reason "$reason" \ + --arg parent "$parent" \ + --argjson pending "$pending" \ + --argjson attention "$attention" ' + { + id:$id, + provider:$provider, + cwd:$cwd, + lastStatus:$status, + archivedAt:null, + requiresAttention:$attention, + attentionReason:(if $reason == "" then null else $reason end), + lastUserMessageAt:$last_user, + lastActivityAt:$activity, + persistence:{provider:$provider,sessionId:("provider-" + $id)}, + runtimeInfo:{provider:$provider,sessionId:("provider-" + $id)}, + testPending:$pending, + testParent:$parent, + testPersistence:true + } + ' > "$W_PASEO/agents/$id.json" +} + +start_owner() { + local id=$1 supervisor_pid tries=0 + # shellcheck disable=SC2016 + PASEO_AGENT_ID="$id" "$W_FAKE_CLAUDE" -c ' + PASEO_AGENT_ID="$1" "$2" -c '\'' + trap "exit 0" TERM INT + while :; do sleep 0.2; done + '\'' _ "$3" /dev/null 2>&1 & + child=$! + printf "%s\n" "$child" > "$4" + wait "$child" + ' _ "$id" "$W_FAKE_CLAUDE" "provider-$id" "$W_PASEO/started-owner-pid" \ + /dev/null 2>&1 & + supervisor_pid=$! + LIVE_PIDS+=("$supervisor_pid") + while [ ! -s "$W_PASEO/started-owner-pid" ]; do + tries=$(( tries + 1 )) + [ "$tries" -lt 50 ] || fail "owner fixture did not start" + sleep 0.02 + done + OWNER_PID=$(cat "$W_PASEO/started-owner-pid") + LIVE_PIDS+=("$OWNER_PID") + printf '%s\n' "$OWNER_PID" > "$W_ROOT/state/.lock" + printf '%s\n' "$OWNER_PID" > "$W_PASEO/owner-pid" + sleep 0.1 +} + +start_descriptor_owner() { + local id=$1 supervisor_pid tries=0 + # shellcheck disable=SC2016 + PASEO_AGENT_ID="$id" \ + FM_HOME="$W_ROOT" \ + FM_ROOT_OVERRIDE="$W_ROOT" \ + PASEO_HOME="$W_PASEO" \ + PATH="$W_FAKEBIN:$PATH" \ + "$W_FAKE_CLAUDE" -c ' + "$1/bin/fm-lock.sh" > "$2" 2>&1 || exit $? + trap "exit 0" TERM INT + while :; do sleep 0.2; done + ' _ "$W_ROOT" "$W_PASEO/owner-lock.out" /dev/null 2>&1 & + supervisor_pid=$! + LIVE_PIDS+=("$supervisor_pid") + while [ ! -s "$W_ROOT/state/.lock" ] || [ ! -s "$W_ROOT/state/.lock.owner" ]; do + tries=$(( tries + 1 )) + [ "$tries" -lt 100 ] || fail "descriptor owner fixture did not acquire the lock: $(cat "$W_PASEO/owner-lock.out" 2>/dev/null)" + sleep 0.02 + done + OWNER_PID=$(cat "$W_ROOT/state/.lock") + LIVE_PIDS+=("$OWNER_PID") + printf '%s\n' "$OWNER_PID" > "$W_PASEO/owner-pid" + sleep 0.1 +} + +run_takeover() { + local agent_id=$1 + RUN_RC=0 + # shellcheck disable=SC2016 + RUN_OUT=$( + env -u PASEO_AGENT_ID \ + FM_HOME="$W_ROOT" \ + FM_ROOT_OVERRIDE="$W_ROOT" \ + PASEO_HOME="$W_PASEO" \ + FM_PRIMARY_OWNER_EXIT_TIMEOUT=3 \ + PATH="$W_FAKEBIN:$PATH" \ + "$W_FAKE_CLAUDE" -c ' + "$1/bin/fm-primary-session.sh" takeover "$2" + rc=$? + : + exit "$rc" + ' _ "$W_ROOT" "$agent_id" 2>&1 + ) || RUN_RC=$? +} + +run_restore() { + local receipt_id=$1 + RUN_RC=0 + RUN_OUT=$( + FM_HOME="$W_ROOT" \ + FM_ROOT_OVERRIDE="$W_ROOT" \ + PASEO_HOME="$W_PASEO" \ + FM_PRIMARY_RELOAD_TIMEOUT=2 \ + FM_TEST_FAKE_CLAUDE="$W_FAKE_CLAUDE" \ + FM_TEST_ROOT="$W_ROOT" \ + PATH="$W_FAKEBIN:$PATH" \ + "$W_ROOT/bin/fm-primary-session.sh" restore "$receipt_id" 2>&1 + ) || RUN_RC=$? +} + +only_receipt() { + local -a receipts + receipts=("$W_ROOT"/data/primary-session-handoffs/*.receipt) + [ "${#receipts[@]}" -eq 1 ] && [ -f "${receipts[0]}" ] || return 1 + printf '%s\n' "${receipts[0]}" +} + +test_owner_mismatch_refuses_without_archive() { + local descriptor_tmp + new_world owner-mismatch + write_agent "$AGENT_A" + write_agent "$AGENT_B" + start_owner "$AGENT_A" + run_takeover "$AGENT_B" + [ "$RUN_RC" -ne 0 ] || fail "owner mismatch unexpectedly took over" + assert_contains "$RUN_OUT" "owner mismatch" "owner mismatch lacked a typed refusal" + kill -0 "$OWNER_PID" 2>/dev/null || fail "owner mismatch stopped the real owner" + [ "$(cat "$W_ROOT/state/.lock")" = "$OWNER_PID" ] || fail "owner mismatch changed the lock" + [ ! -e "$W_PASEO/calls.log" ] || fail "owner mismatch invoked Paseo lifecycle" + + new_world owner-identity-mismatch + write_agent "$AGENT_A" + start_descriptor_owner "$AGENT_A" + descriptor_tmp="$W_ROOT/state/.lock.owner.test" + awk ' + /^owner_identity_sha256=/ { + print "owner_identity_sha256=ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + next + } + { print } + ' "$W_ROOT/state/.lock.owner" > "$descriptor_tmp" + mv "$descriptor_tmp" "$W_ROOT/state/.lock.owner" + run_takeover "$AGENT_A" + [ "$RUN_RC" -ne 0 ] || fail "process-identity mismatch unexpectedly took over" + assert_contains "$RUN_OUT" "owner mismatch" "process-identity mismatch lacked a typed refusal" + kill -0 "$OWNER_PID" 2>/dev/null || fail "process-identity mismatch stopped the owner" + [ "$(cat "$W_ROOT/state/.lock")" = "$OWNER_PID" ] || fail "process-identity mismatch changed the lock" + [ ! -e "$W_PASEO/calls.log" ] || fail "process-identity mismatch invoked Paseo lifecycle" + pass "primary-session: requested external session and process identity must exactly match the live lock owner" +} + +test_busy_pending_and_unknown_refuse() { + new_world busy + write_agent "$AGENT_A" running 0 false "" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -ne 0 ] || fail "busy owner unexpectedly took over" + assert_contains "$RUN_OUT" "busy:" "busy owner did not retain its distinct classification" + [ ! -e "$W_PASEO/calls.log" ] || fail "busy owner invoked Paseo archive" + + new_world pending + write_agent "$AGENT_A" idle 1 + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -ne 0 ] || fail "pending-permission owner unexpectedly took over" + assert_contains "$RUN_OUT" "waiting-on-captain:" "pending permission did not classify as waiting on captain" + [ ! -e "$W_PASEO/calls.log" ] || fail "pending owner invoked Paseo archive" + + new_world unknown + write_agent "$AGENT_A" mystery + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -ne 0 ] || fail "unknown owner unexpectedly took over" + assert_contains "$RUN_OUT" "unknown:" "unknown owner lacked a distinct classification" + [ ! -e "$W_PASEO/calls.log" ] || fail "unknown owner invoked Paseo archive" + pass "primary-session: busy, pending-action, and unknown owners fail closed with distinct states" +} + +test_wedged_and_attached_child_refuse() { + new_world wedged + write_agent "$AGENT_A" running 0 false "" "2020-01-01T00:00:00Z" "" "2019-01-01T00:00:00Z" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -ne 0 ] || fail "wedged owner unexpectedly took over" + assert_contains "$RUN_OUT" "wedged:" "wedged owner lacked its distinct classification" + + new_world child + write_agent "$AGENT_A" + write_agent "$AGENT_B" idle 0 false "" "2026-07-29T07:00:00Z" "$AGENT_A" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -ne 0 ] || fail "owner with attached child unexpectedly took over" + assert_contains "$RUN_OUT" "attached child agents" "attached child refusal was not explicit" + [ ! -e "$W_PASEO/calls.log" ] || fail "attached child owner invoked Paseo archive" + pass "primary-session: wedged and attached-child sessions remain suspended from takeover eligibility" +} + +test_suspend_failure_and_live_tree_refuse() { + new_world suspend-failure + write_agent "$AGENT_A" + : > "$W_PASEO/suspend-fail" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -ne 0 ] || fail "failed Paseo suspend unexpectedly took over" + assert_contains "$RUN_OUT" "soft archive failed" "suspend failure lacked a typed refusal" + kill -0 "$OWNER_PID" 2>/dev/null || fail "suspend failure stopped the owner" + [ "$(cat "$W_ROOT/state/.lock")" = "$OWNER_PID" ] || fail "suspend failure changed the lock" + [ ! -d "$W_ROOT/data/primary-session-handoffs" ] \ + || [ -z "$(find "$W_ROOT/data/primary-session-handoffs" -name '*.receipt' -print -quit)" ] \ + || fail "suspend failure published a completed receipt" + + new_world tree-alive + write_agent "$AGENT_A" + : > "$W_PASEO/keep-owner-alive" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -ne 0 ] || fail "live owner tree unexpectedly took over" + assert_contains "$RUN_OUT" "process tree remained alive" "live-tree failure lacked a typed refusal" + kill -0 "$OWNER_PID" 2>/dev/null || fail "live-tree fixture owner unexpectedly died" + [ "$(cat "$W_ROOT/state/.lock")" = "$OWNER_PID" ] || fail "live-tree failure changed the lock" + receipt=$(only_receipt) || fail "live-tree failure did not publish its recovery receipt" + [ "$(record_last_value "$receipt" state)" = suspend-incomplete ] \ + || fail "live-tree receipt did not record suspend-incomplete" + pass "primary-session: suspend failure and surviving owner process tree never claim the lock" +} + +test_successful_takeover_preserves_fleet_and_records_receipt() { + new_world successful + write_agent "$AGENT_A" + printf 'window=firstmate:fm-task\nworktree=/fixture/task\n' > "$W_ROOT/state/task.meta" + printf '1\t1\tsignal\ttask\tfixture wake\n' > "$W_ROOT/state/.wake-queue" + printf 'secondmate fixture\n' > "$W_ROOT/data/secondmates.md" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -eq 0 ] || fail "safe idle takeover failed: $RUN_OUT" + assert_contains "$RUN_OUT" "takeover complete" "successful takeover lacked completion output" + new_owner=$(cat "$W_ROOT/state/.lock") + [ "$new_owner" != "$OWNER_PID" ] || fail "successful takeover kept the old lock pid" + [ -s "$W_ROOT/state/session-start.log" ] || fail "successful takeover did not run ordinary session start" + receipt=$(only_receipt) || fail "successful takeover did not publish exactly one receipt" + [ "$(record_last_value "$receipt" state)" = active-successor ] \ + || fail "successful receipt did not record active-successor" + [ "$(record_last_value "$receipt" provider_session_id)" = "provider-$AGENT_A" ] \ + || fail "receipt lost the provider-native session id" + assert_grep "window=firstmate:fm-task" "$W_ROOT/state/task.meta" "task metadata changed during takeover" + assert_grep "fixture wake" "$W_ROOT/state/.wake-queue" "wake queue changed during takeover" + assert_grep "secondmate fixture" "$W_ROOT/data/secondmates.md" "secondmate registry changed during takeover" + pass "primary-session: safe idle takeover uses stale-lock acquisition and preserves fleet state" +} + +test_rate_limited_takeover_is_distinct_and_recoverable() { + new_world rate-limited + write_agent "$AGENT_A" + : > "$W_PASEO/quota-blocked" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -eq 0 ] || fail "rate-limited idle takeover failed: $RUN_OUT" + receipt=$(only_receipt) || fail "rate-limited takeover lacked a receipt" + [ "$(record_last_value "$receipt" classification)" = paused-rate-limited ] \ + || fail "rate-limited target was not distinguished from ordinary idle" + pass "primary-session: fresh exhausted quota evidence produces a distinct recoverable pause" +} + +test_concurrent_takeovers_admit_one_successor() { + local rc1 rc2 successes archive_count + new_world concurrent + write_agent "$AGENT_A" + start_owner "$AGENT_A" + # shellcheck disable=SC2016 + env -u PASEO_AGENT_ID \ + FM_HOME="$W_ROOT" FM_ROOT_OVERRIDE="$W_ROOT" PASEO_HOME="$W_PASEO" \ + FM_PRIMARY_OWNER_EXIT_TIMEOUT=3 PATH="$W_FAKEBIN:$PATH" \ + "$W_FAKE_CLAUDE" -c '"$1/bin/fm-primary-session.sh" takeover "$2"; rc=$?; :; exit "$rc"' \ + _ "$W_ROOT" "$AGENT_A" > "$W_DIR/out1" 2>&1 & + p1=$! + # shellcheck disable=SC2016 + env -u PASEO_AGENT_ID \ + FM_HOME="$W_ROOT" FM_ROOT_OVERRIDE="$W_ROOT" PASEO_HOME="$W_PASEO" \ + FM_PRIMARY_OWNER_EXIT_TIMEOUT=3 PATH="$W_FAKEBIN:$PATH" \ + "$W_FAKE_CLAUDE" -c '"$1/bin/fm-primary-session.sh" takeover "$2"; rc=$?; :; exit "$rc"' \ + _ "$W_ROOT" "$AGENT_A" > "$W_DIR/out2" 2>&1 & + p2=$! + rc1=0 + wait "$p1" || rc1=$? + rc2=0 + wait "$p2" || rc2=$? + successes=0 + [ "$rc1" -eq 0 ] && successes=$(( successes + 1 )) + [ "$rc2" -eq 0 ] && successes=$(( successes + 1 )) + [ "$successes" -eq 1 ] || fail "concurrent takeover expected one success, got rc1=$rc1 rc2=$rc2" + archive_count=$(grep -c '^archive ' "$W_PASEO/calls.log") + [ "$archive_count" -eq 1 ] || fail "concurrent takeover archived $archive_count times" + pass "primary-session: concurrent takeover attempts admit one suspension and one successor" +} + +test_restore_refuses_live_successor() { + new_world restore-refusal + write_agent "$AGENT_A" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + expect_code 0 "$RUN_RC" "takeover before restore-refusal test" + receipt=$(only_receipt) || fail "restore-refusal setup lacked a receipt" + receipt_id=$(record_last_value "$receipt" receipt_id) + "$W_FAKE_CLAUDE" -c ' + trap "exit 0" TERM INT + while :; do read -r -t 1 _ || true; done + ' /dev/null 2>&1 & + live_successor=$! + LIVE_PIDS+=("$live_successor") + printf '%s\n' "$live_successor" > "$W_ROOT/state/.lock" + run_restore "$receipt_id" + [ "$RUN_RC" -ne 0 ] || fail "restore under a live successor unexpectedly succeeded" + assert_contains "$RUN_OUT" "restore refused: live pid" "live-successor restore refusal was not explicit" + [ -e "$W_PASEO/archived-$AGENT_A" ] || fail "refused restore unarchived the provider" + [ "$(grep -c '^reload ' "$W_PASEO/calls.log" || true)" -eq 0 ] || fail "refused restore invoked reload" + pass "primary-session: restore fails closed while another live primary owns the lock" +} + +test_recoverable_happy_path_reacquires_normally() { + new_world restore-happy + write_agent "$AGENT_A" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + expect_code 0 "$RUN_RC" "takeover before restore happy path" + receipt=$(only_receipt) || fail "restore happy-path setup lacked a receipt" + receipt_id=$(record_last_value "$receipt" receipt_id) + : > "$W_PASEO/reload-spawn" + run_restore "$receipt_id" + [ "$RUN_RC" -eq 0 ] || fail "explicit restore failed: $RUN_OUT" + deadline=$(( $(date +%s) + 5 )) + while [ "$(record_last_value "$receipt" state)" != restored ]; do + [ "$(date +%s)" -lt "$deadline" ] || fail "restored provider did not reacquire through normal session start" + sleep 0.1 + done + restored_pid=$(cat "$W_PASEO/restored-pid") + LIVE_PIDS+=("$restored_pid") + [ "$(cat "$W_ROOT/state/.lock")" = "$restored_pid" ] \ + || fail "restored provider did not become the canonical lock owner" + [ "$(record_last_value "$W_ROOT/state/.lock.owner" external_session_id)" = "$AGENT_A" ] \ + || fail "restored lock descriptor lost the Paseo agent id" + assert_contains "$RUN_OUT" "must complete the native session-start nudge" \ + "restore output did not state the reacquisition requirement" + pass "primary-session: archived provider reload remains recoverable and reacquires through normal session start" +} + +test_scan_is_read_only_and_privacy_safe() { + new_world scan + write_agent "$AGENT_A" idle 1 + write_agent "$AGENT_B" idle 0 true finished + write_agent "$AGENT_C" idle 0 true "private fixture prose" + before=$(git -C "$W_ROOT" status --porcelain) + out=$(FM_HOME="$W_ROOT" FM_ROOT_OVERRIDE="$W_ROOT" PASEO_HOME="$W_PASEO" \ + PATH="$W_FAKEBIN:$PATH" "$W_ROOT/bin/fm-primary-session.sh" scan --json) + after=$(git -C "$W_ROOT" status --porcelain) + [ "$before" = "$after" ] || fail "read-only scan changed the fixture checkout" + [ "$(jq 'length' <<< "$out")" -eq 3 ] || fail "scan did not return all captain-action sessions" + jq -e --arg a "$AGENT_A" --arg b "$AGENT_B" --arg c "$AGENT_C" ' + any(.[]; .external_session_id == $a and .action == "permission") + and any(.[]; .external_session_id == $b and .action == "finished") + and any(.[]; .external_session_id == $c and .action == "attention") + and all(.[]; has("name") | not) + ' <<< "$out" >/dev/null || fail "scan output lost action identity or exposed titles" + assert_not_contains "$out" "private fixture prose" "scan exposed raw attention prose" + [ ! -e "$W_PASEO/calls.log" ] || fail "scan invoked a Paseo lifecycle command" + pass "primary-session: fleet-wide captain-action scan is read-only and omits prompt/title prose" +} + +test_nested_paseo_state_resolves_uniquely() { + local receipt + new_world nested-paseo-state + write_agent "$AGENT_A" + mkdir -p "$W_PASEO/agents/workspace-fixture" + mv "$W_PASEO/agents/$AGENT_A.json" "$W_PASEO/agents/workspace-fixture/$AGENT_A.json" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -eq 0 ] || fail "workspace-scoped Paseo takeover failed: $RUN_OUT" + receipt=$(only_receipt) || fail "workspace-scoped Paseo takeover lacked a receipt" + [ "$(record_last_value "$receipt" provider_session_id)" = "provider-$AGENT_A" ] \ + || fail "workspace-scoped Paseo state resolved the wrong provider session" + pass "primary-session: workspace-scoped Paseo state resolves by unique external session id" +} + +test_owner_mismatch_refuses_without_archive +test_busy_pending_and_unknown_refuse +test_wedged_and_attached_child_refuse +test_suspend_failure_and_live_tree_refuse +test_successful_takeover_preserves_fleet_and_records_receipt +test_rate_limited_takeover_is_distinct_and_recoverable +test_concurrent_takeovers_admit_one_successor +test_restore_refuses_live_successor +test_recoverable_happy_path_reacquires_normally +test_scan_is_read_only_and_privacy_safe +test_nested_paseo_state_resolves_uniquely diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 9bcf04eddb..efc04e52c6 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -153,6 +153,7 @@ make_fake_ps_harness() { set -u harness=${FM_FAKE_HARNESS:-claude} case "$*" in + *"lstart="*) printf 'Tue Jul 29 01:00:00 2026 %s\n' "$harness"; exit 0 ;; *"comm="*) printf '/usr/local/bin/%s\n' "$harness"; exit 0 ;; *"args="*) printf '%s\n' "$harness"; exit 0 ;; esac @@ -699,6 +700,13 @@ for argument in "$@"; do previous=$argument done case "$*" in + *"lstart="*) + if [ -f "$FM_FAKE_LOCK_STATE/harness-$pid" ]; then + printf '%s\n' 'Tue Jul 29 01:00:00 2026 claude' + else + printf '%s\n' 'Tue Jul 29 01:00:00 2026 bash' + fi + ;; *"comm="*) if [ -f "$FM_FAKE_LOCK_STATE/harness-$pid" ]; then printf '%s\n' /usr/local/bin/claude From e2e91d9849a742f108ec0b79fee379bce6d4c1df Mon Sep 17 00:00:00 2001 From: tmad4000 Date: Wed, 29 Jul 2026 02:18:13 -0700 Subject: [PATCH 2/8] no-mistakes(review): Harden primary-session lock handoffs --- bin/fm-lock.sh | 12 +++++- bin/fm-primary-session-lib.sh | 66 ++++++++++++++++++++++++++++++++ bin/fm-primary-session.sh | 21 +++++++++- tests/fm-primary-session.test.sh | 48 ++++++++++++++++++++++- tests/fm-session-start.test.sh | 8 ++++ 5 files changed, 152 insertions(+), 3 deletions(-) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 49e698fe52..df7f206074 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -41,6 +41,7 @@ if [ "${1:-}" = "status" ]; then fi me=$(fm_harness_ancestry_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; } +incomplete_reason= probe=$(mktemp "$STATE/.lock-write.XXXXXX" 2>/dev/null) || { echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 @@ -64,6 +65,11 @@ trap 'exit 1' HUP INT TERM fm_lock_acquire_wait "$CLAIM_LOCK" CLAIM_LOCK_HELD=1 +if ! incomplete_reason=$(fm_primary_suspend_incomplete_clear); then + echo "error: ${incomplete_reason:-unresolved suspend-incomplete primary-session handoff}; operate read-only until resolved" >&2 + exit 1 +fi + if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then if [ ! -f "$LOCK" ] || [ -L "$LOCK" ]; then echo "error: session lock is not a regular file; operate read-only until resolved" >&2 @@ -91,7 +97,11 @@ if [ ! -f "$LOCK" ] || [ -L "$LOCK" ] || [ "$written" != "$me" ]; then exit 1 fi if ! fm_primary_owner_publish "$STATE" "$me"; then - echo "error: cannot publish verified primary-session owner metadata; lock remains held by this live session and it must operate read-only until resolved" >&2 + if fm_primary_lock_rollback_if_exact_owner "$STATE" "$me"; then + echo "error: cannot publish verified primary-session owner metadata; rolled back this session lock claim; operate read-only until resolved" >&2 + else + echo "error: cannot publish verified primary-session owner metadata; could not prove safe rollback of this session lock claim; operate read-only until resolved" >&2 + fi exit 1 fi release_claim_lock diff --git a/bin/fm-primary-session-lib.sh b/bin/fm-primary-session-lib.sh index 17543ae172..f173e51262 100644 --- a/bin/fm-primary-session-lib.sh +++ b/bin/fm-primary-session-lib.sh @@ -57,6 +57,12 @@ fm_primary_receipt_append_state() { } >> "$receipt" } +fm_primary_receipt_append_resolution() { + local receipt=$1 now + now=$(fm_primary_now_utc) || return 1 + fm_primary_receipt_append_state "$receipt" suspend-resolved suspend_resolved_at "$now" +} + fm_primary_paseo_home() { if [ -n "${PASEO_HOME:-}" ]; then printf '%s\n' "$PASEO_HOME" @@ -123,6 +129,66 @@ fm_primary_mark_reacquired_receipts() { done } +fm_primary_suspend_incomplete_clear() { + local fingerprint receipt receipt_home receipt_state remaining token pid live_pids blocked=0 resolved=0 + local block_reason= + local -a tokens + [ -d "$FM_PRIMARY_LIB_DATA/primary-session-handoffs" ] || return 0 + fingerprint=$(fm_primary_home_fingerprint) || return 1 + for receipt in "$FM_PRIMARY_LIB_DATA"/primary-session-handoffs/*.receipt; do + [ -e "$receipt" ] || continue + [ -f "$receipt" ] && [ ! -L "$receipt" ] || continue + [ "$(fm_primary_receipt_last_value "$receipt" schema 2>/dev/null || true)" = "fm-primary-session-handoff.v1" ] || continue + receipt_home=$(fm_primary_receipt_last_value "$receipt" home_fingerprint 2>/dev/null || true) + [ "$receipt_home" = "$fingerprint" ] || continue + receipt_state=$(fm_primary_receipt_last_value "$receipt" state 2>/dev/null || true) + [ "$receipt_state" = suspend-incomplete ] || continue + remaining=$(fm_primary_receipt_last_value "$receipt" remaining_pids 2>/dev/null || true) + if [ -z "$remaining" ]; then + block_reason="unresolved suspend-incomplete primary-session receipt lacks a provable remaining process set: $receipt" + blocked=1 + continue + fi + live_pids= + IFS=, read -r -a tokens <<< "$remaining" + for token in "${tokens[@]}"; do + pid=${token%%:*} + case "$pid" in ''|*[!0-9]*) continue ;; esac + if fm_process_pid_running "$pid"; then + live_pids="${live_pids}${live_pids:+,}${pid}" + fi + done + if [ -n "$live_pids" ]; then + block_reason="unresolved suspend-incomplete primary-session receipt still has live captured pids ($live_pids): $receipt" + blocked=1 + continue + fi + fm_primary_receipt_append_resolution "$receipt" || { + block_reason="unresolved suspend-incomplete primary-session receipt could not be safely resolved: $receipt" + blocked=1 + continue + } + resolved=1 + done + if [ "$blocked" -ne 0 ]; then + printf '%s\n' "$block_reason" + return 1 + fi + [ "$resolved" -eq 0 ] || return 0 + return 0 +} + +fm_primary_lock_rollback_if_exact_owner() { + local state=$1 owner_pid=$2 lock current + lock="$state/.lock" + case "$owner_pid" in ''|*[!0-9]*) return 1 ;; esac + [ -f "$lock" ] && [ ! -L "$lock" ] || return 1 + current=$(cat "$lock" 2>/dev/null) || return 1 + [ "$current" = "$owner_pid" ] || return 1 + rm -f "$lock" || return 1 + [ ! -e "$lock" ] && [ ! -L "$lock" ] || return 1 +} + fm_primary_owner_publish() { local state=$1 owner_pid=$2 descriptor tmp harness manager external_id provider_session recorded_at local agent_file state_provider owner_identity owner_identity_hash diff --git a/bin/fm-primary-session.sh b/bin/fm-primary-session.sh index 5165868ce3..e3702f2db4 100755 --- a/bin/fm-primary-session.sh +++ b/bin/fm-primary-session.sh @@ -417,6 +417,18 @@ wait_tree_gone() { done } +remaining_pid_atoms() { + local remaining=$1 token pid out= + local -a tokens + IFS=, read -r -a tokens <<< "$remaining" + for token in "${tokens[@]}"; do + pid=${token%%:*} + case "$pid" in ''|*[!0-9]*) continue ;; esac + out="${out}${out:+,}${pid}" + done + printf '%s\n' "$out" +} + receipt_prepare() { local agent_id=$1 owner_pid=$2 owner_harness=$3 provider=$4 classification=$5 local provider_session fingerprint identity identity_hash receipt_stamp draft receipt_id @@ -479,7 +491,7 @@ release_claim_lock() { takeover() { local agent_id=$1 new_owner_pid owner_pid owner_harness classification reason inspect provider - local tree draft archive_out post receipt new_lock_pid now rc=0 + local tree draft archive_out post receipt new_lock_pid now remaining rc=0 fm_primary_atom_valid "$agent_id" || die "invalid Paseo agent id" require_takeover_tools primary_scope_required @@ -522,6 +534,13 @@ takeover() { die "Paseo archive returned success but the target is not archived" fi if ! wait_tree_gone "$tree"; then + remaining=$(remaining_pid_atoms "$FM_PRIMARY_REMAINING_PIDS") + if [ -n "$remaining" ]; then + printf 'remaining_pids=%s\n' "$remaining" >> "$draft" \ + || die "owner process tree remained alive and the remaining process receipt could not be recorded" + else + die "owner process tree remained alive and the remaining process receipt could not be recorded" + fi receipt=$(receipt_publish "$draft" suspend-incomplete suspend_incomplete_at) \ || die "owner process tree remained alive and the incomplete handoff receipt could not be published" die "owner process tree remained alive after soft archive ($FM_PRIMARY_REMAINING_PIDS); lock was left untouched; receipt: $receipt" diff --git a/tests/fm-primary-session.test.sh b/tests/fm-primary-session.test.sh index ae8063de23..ebf20a255d 100755 --- a/tests/fm-primary-session.test.sh +++ b/tests/fm-primary-session.test.sh @@ -333,6 +333,24 @@ run_restore() { ) || RUN_RC=$? } +run_lock_acquire() { + RUN_RC=0 + # shellcheck disable=SC2016 + RUN_OUT=$( + env -u PASEO_AGENT_ID \ + FM_HOME="$W_ROOT" \ + FM_ROOT_OVERRIDE="$W_ROOT" \ + PASEO_HOME="$W_PASEO" \ + PATH="$W_FAKEBIN:$PATH" \ + "$W_FAKE_CLAUDE" -c ' + "$1/bin/fm-lock.sh" + rc=$? + : + exit "$rc" + ' _ "$W_ROOT" 2>&1 + ) || RUN_RC=$? +} + only_receipt() { local -a receipts receipts=("$W_ROOT"/data/primary-session-handoffs/*.receipt) @@ -446,7 +464,34 @@ test_suspend_failure_and_live_tree_refuse() { receipt=$(only_receipt) || fail "live-tree failure did not publish its recovery receipt" [ "$(record_last_value "$receipt" state)" = suspend-incomplete ] \ || fail "live-tree receipt did not record suspend-incomplete" - pass "primary-session: suspend failure and surviving owner process tree never claim the lock" + [ "$(record_last_value "$receipt" remaining_pids)" = "$OWNER_PID" ] \ + || fail "live-tree receipt did not record the surviving pid set" + run_lock_acquire + [ "$RUN_RC" -ne 0 ] || fail "ordinary stale-lock acquisition bypassed suspend-incomplete receipt" + assert_contains "$RUN_OUT" "unresolved suspend-incomplete primary-session receipt still has live captured pids" \ + "ordinary lock refusal did not cite the unresolved incomplete handoff" + [ "$(cat "$W_ROOT/state/.lock")" = "$OWNER_PID" ] || fail "blocked stale acquisition changed the old lock" + kill "$OWNER_PID" 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$OWNER_PID" 2>/dev/null || break + sleep 0.05 + done + run_lock_acquire + [ "$RUN_RC" -eq 0 ] || fail "ordinary stale-lock acquisition did not proceed after captured tree exited: $RUN_OUT" + [ "$(record_last_value "$receipt" state)" = suspend-resolved ] \ + || fail "ordinary stale-lock acquisition did not resolve the incomplete receipt" + pass "primary-session: suspend failure and surviving owner process tree block stale-lock acquisition until resolved" +} + +test_lock_publish_failure_rolls_back_own_claim() { + new_world publish-failure + mkdir "$W_ROOT/state/.lock.owner" + run_lock_acquire + [ "$RUN_RC" -ne 0 ] || fail "lock acquisition unexpectedly succeeded despite descriptor publication failure" + assert_contains "$RUN_OUT" "rolled back this session lock claim" \ + "lock publication failure did not report a safe rollback" + [ ! -e "$W_ROOT/state/.lock" ] || fail "failed descriptor publication left this session's numeric lock behind" + pass "fm-lock: descriptor publication failure rolls back only its own numeric claim" } test_successful_takeover_preserves_fleet_and_records_receipt() { @@ -609,6 +654,7 @@ test_owner_mismatch_refuses_without_archive test_busy_pending_and_unknown_refuse test_wedged_and_attached_child_refuse test_suspend_failure_and_live_tree_refuse +test_lock_publish_failure_rolls_back_own_claim test_successful_takeover_preserves_fleet_and_records_receipt test_rate_limited_takeover_is_distinct_and_recoverable test_concurrent_takeovers_admit_one_successor diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index efc04e52c6..31f894ff26 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -175,6 +175,14 @@ for arg in "\$@"; do prev="\$arg" done case "\$*" in + *"lstart="*) + if [ "\$pid" = "$holder_pid" ]; then + printf 'Tue Jul 29 01:00:00 2026 pi\n' + else + printf 'Tue Jul 29 01:00:00 2026 zsh\n' + fi + exit 0 + ;; *"comm="*) if [ "\$pid" = "$holder_pid" ]; then printf '/usr/local/bin/pi\n' From a8c1bd92452d256fdc69a541d681a4d05311311a Mon Sep 17 00:00:00 2001 From: tmad4000 Date: Wed, 29 Jul 2026 02:21:09 -0700 Subject: [PATCH 3/8] no-mistakes(review): Captain: harden restore incomplete guard --- bin/fm-primary-session.sh | 15 +++++++++------ tests/fm-primary-session.test.sh | 6 ++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/bin/fm-primary-session.sh b/bin/fm-primary-session.sh index e3702f2db4..c24f477943 100755 --- a/bin/fm-primary-session.sh +++ b/bin/fm-primary-session.sh @@ -577,7 +577,7 @@ resolve_receipt() { restore() { local receipt_id=$1 receipt fingerprint receipt_home state agent_id manager harness provider inspect out - local deadline post now lock_pid + local deadline post now lock_pid incomplete_reason require_read_tools primary_scope_required receipt=$(resolve_receipt "$receipt_id") || die "receipt not found or invalid: $receipt_id" @@ -586,11 +586,6 @@ restore() { fingerprint=$(fm_primary_home_fingerprint) || die "cannot fingerprint this Firstmate home" receipt_home=$(fm_primary_receipt_last_value "$receipt" home_fingerprint 2>/dev/null || true) [ "$receipt_home" = "$fingerprint" ] || die "receipt belongs to a different Firstmate home" - state=$(fm_primary_receipt_last_value "$receipt" state 2>/dev/null || true) - case "$state" in - active-successor|suspended|suspend-incomplete|restore-failed) ;; - *) die "receipt state '$state' is not eligible for restore" ;; - esac manager=$(fm_primary_receipt_last_value "$receipt" manager 2>/dev/null || true) harness=$(fm_primary_receipt_last_value "$receipt" harness 2>/dev/null || true) provider=$(fm_primary_receipt_last_value "$receipt" provider 2>/dev/null || true) @@ -605,6 +600,14 @@ restore() { trap release_claim_lock EXIT trap 'exit 1' HUP INT TERM acquire_claim_lock + if ! incomplete_reason=$(fm_primary_suspend_incomplete_clear); then + die "${incomplete_reason:-unresolved suspend-incomplete primary-session handoff}; restore refused until resolved" + fi + state=$(fm_primary_receipt_last_value "$receipt" state 2>/dev/null || true) + case "$state" in + active-successor|suspended|suspend-resolved|restore-failed) ;; + *) die "receipt state '$state' is not eligible for restore" ;; + esac if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then [ -f "$LOCK" ] && [ ! -L "$LOCK" ] || die "session lock is not a regular file" lock_pid=$(cat "$LOCK" 2>/dev/null) || die "session lock is unreadable" diff --git a/tests/fm-primary-session.test.sh b/tests/fm-primary-session.test.sh index ebf20a255d..6baa31ecc7 100755 --- a/tests/fm-primary-session.test.sh +++ b/tests/fm-primary-session.test.sh @@ -471,6 +471,12 @@ test_suspend_failure_and_live_tree_refuse() { assert_contains "$RUN_OUT" "unresolved suspend-incomplete primary-session receipt still has live captured pids" \ "ordinary lock refusal did not cite the unresolved incomplete handoff" [ "$(cat "$W_ROOT/state/.lock")" = "$OWNER_PID" ] || fail "blocked stale acquisition changed the old lock" + receipt_id=$(record_last_value "$receipt" receipt_id) + run_restore "$receipt_id" + [ "$RUN_RC" -ne 0 ] || fail "restore bypassed unresolved suspend-incomplete receipt" + assert_contains "$RUN_OUT" "unresolved suspend-incomplete primary-session receipt still has live captured pids" \ + "restore refusal did not cite the unresolved incomplete handoff" + [ "$(grep -c '^reload ' "$W_PASEO/calls.log" || true)" -eq 0 ] || fail "refused suspend-incomplete restore invoked reload" kill "$OWNER_PID" 2>/dev/null || true for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$OWNER_PID" 2>/dev/null || break From 0a59db26f250e0b23e88e1b2223aea66a5605c5b Mon Sep 17 00:00:00 2001 From: tmad4000 Date: Wed, 29 Jul 2026 02:30:25 -0700 Subject: [PATCH 4/8] no-mistakes(review): Captain, preserve recoverable handoff receipts --- bin/fm-primary-session-lib.sh | 39 +++++++++++------- bin/fm-primary-session.sh | 42 +++++++++++++------- docs/primary-session-handoff.md | 5 ++- docs/verification/primary-session-handoff.md | 5 ++- tests/fm-primary-session.test.sh | 33 +++++++++++++-- 5 files changed, 88 insertions(+), 36 deletions(-) diff --git a/bin/fm-primary-session-lib.sh b/bin/fm-primary-session-lib.sh index f173e51262..794b658570 100644 --- a/bin/fm-primary-session-lib.sh +++ b/bin/fm-primary-session-lib.sh @@ -63,6 +63,20 @@ fm_primary_receipt_append_resolution() { fm_primary_receipt_append_state "$receipt" suspend-resolved suspend_resolved_at "$now" } +fm_primary_receipt_live_pid_set() { + local pids=$1 token pid live_pids= + local -a tokens + IFS=, read -r -a tokens <<< "$pids" + for token in "${tokens[@]}"; do + pid=${token%%:*} + case "$pid" in ''|*[!0-9]*) continue ;; esac + if fm_process_pid_running "$pid"; then + live_pids="${live_pids}${live_pids:+,}${pid}" + fi + done + printf '%s\n' "$live_pids" +} + fm_primary_paseo_home() { if [ -n "${PASEO_HOME:-}" ]; then printf '%s\n' "$PASEO_HOME" @@ -130,9 +144,8 @@ fm_primary_mark_reacquired_receipts() { } fm_primary_suspend_incomplete_clear() { - local fingerprint receipt receipt_home receipt_state remaining token pid live_pids blocked=0 resolved=0 + local fingerprint receipt receipt_home receipt_state remaining live_pids blocked=0 resolved=0 local block_reason= - local -a tokens [ -d "$FM_PRIMARY_LIB_DATA/primary-session-handoffs" ] || return 0 fingerprint=$(fm_primary_home_fingerprint) || return 1 for receipt in "$FM_PRIMARY_LIB_DATA"/primary-session-handoffs/*.receipt; do @@ -142,27 +155,23 @@ fm_primary_suspend_incomplete_clear() { receipt_home=$(fm_primary_receipt_last_value "$receipt" home_fingerprint 2>/dev/null || true) [ "$receipt_home" = "$fingerprint" ] || continue receipt_state=$(fm_primary_receipt_last_value "$receipt" state 2>/dev/null || true) - [ "$receipt_state" = suspend-incomplete ] || continue - remaining=$(fm_primary_receipt_last_value "$receipt" remaining_pids 2>/dev/null || true) + case "$receipt_state" in + suspend-incomplete) remaining=$(fm_primary_receipt_last_value "$receipt" remaining_pids 2>/dev/null || true) ;; + archive-requested) remaining=$(fm_primary_receipt_last_value "$receipt" captured_pids 2>/dev/null || true) ;; + *) continue ;; + esac if [ -z "$remaining" ]; then - block_reason="unresolved suspend-incomplete primary-session receipt lacks a provable remaining process set: $receipt" + block_reason="unresolved $receipt_state primary-session receipt lacks a provable process set: $receipt" blocked=1 continue fi - live_pids= - IFS=, read -r -a tokens <<< "$remaining" - for token in "${tokens[@]}"; do - pid=${token%%:*} - case "$pid" in ''|*[!0-9]*) continue ;; esac - if fm_process_pid_running "$pid"; then - live_pids="${live_pids}${live_pids:+,}${pid}" - fi - done + live_pids=$(fm_primary_receipt_live_pid_set "$remaining") || live_pids=unknown if [ -n "$live_pids" ]; then - block_reason="unresolved suspend-incomplete primary-session receipt still has live captured pids ($live_pids): $receipt" + block_reason="unresolved $receipt_state primary-session receipt still has live captured pids ($live_pids): $receipt" blocked=1 continue fi + [ "$receipt_state" = suspend-incomplete ] || continue fm_primary_receipt_append_resolution "$receipt" || { block_reason="unresolved suspend-incomplete primary-session receipt could not be safely resolved: $receipt" blocked=1 diff --git a/bin/fm-primary-session.sh b/bin/fm-primary-session.sh index c24f477943..75e8f18e0e 100755 --- a/bin/fm-primary-session.sh +++ b/bin/fm-primary-session.sh @@ -9,9 +9,9 @@ # fm-primary-session.sh takeover # Prove that the exact local Paseo agent owns this home's live numeric # session lock, classify it from structured Paseo and quota evidence, -# refuse every unsafe state, soft-archive the idle recoverable provider, -# verify the captured owner process tree is gone, publish an append-only -# privacy-safe receipt, acquire the unchanged stale numeric lock through +# refuse every unsafe state, publish an append-only privacy-safe receipt, +# soft-archive the idle recoverable provider, verify the captured owner +# process tree is gone, acquire the unchanged stale numeric lock through # bin/fm-lock.sh, and run the ordinary session-start digest. # # fm-primary-session.sh restore @@ -429,8 +429,17 @@ remaining_pid_atoms() { printf '%s\n' "$out" } +pid_lines_to_atoms() { + local pid out= + while IFS= read -r pid; do + case "$pid" in ''|*[!0-9]*) continue ;; esac + out="${out}${out:+,}${pid}" + done + printf '%s\n' "$out" +} + receipt_prepare() { - local agent_id=$1 owner_pid=$2 owner_harness=$3 provider=$4 classification=$5 + local agent_id=$1 owner_pid=$2 owner_harness=$3 provider=$4 classification=$5 captured_pids=$6 local provider_session fingerprint identity identity_hash receipt_stamp draft receipt_id mkdir -p "$HANDOFF_DIR" || return 1 [ -d "$HANDOFF_DIR" ] && [ ! -L "$HANDOFF_DIR" ] || return 1 @@ -455,6 +464,7 @@ receipt_prepare() { printf 'old_owner_pid=%s\n' "$owner_pid" printf 'old_owner_identity_sha256=%s\n' "$identity_hash" printf 'classification=%s\n' "$classification" + printf 'captured_pids=%s\n' "$captured_pids" printf 'prepared_at=%s\n' "$(fm_primary_now_utc)" printf 'state=preparing\n' } > "$draft"; then @@ -491,7 +501,7 @@ release_claim_lock() { takeover() { local agent_id=$1 new_owner_pid owner_pid owner_harness classification reason inspect provider - local tree draft archive_out post receipt new_lock_pid now remaining rc=0 + local tree captured_pids draft archive_out post receipt new_lock_pid now remaining rc=0 fm_primary_atom_valid "$agent_id" || die "invalid Paseo agent id" require_takeover_tools primary_scope_required @@ -519,33 +529,37 @@ takeover() { grep -qx "$owner_pid" <<< "$tree" || die "captured process tree omitted its owner" grep -qx "$new_owner_pid" <<< "$tree" \ && die "takeover refused: the successor primary is inside the target provider process tree" - draft=$(receipt_prepare "$agent_id" "$owner_pid" "$owner_harness" "$provider" "$classification") \ + captured_pids=$(pid_lines_to_atoms <<< "$tree") + [ -n "$captured_pids" ] || die "captured process tree had no restorable process evidence" + draft=$(receipt_prepare "$agent_id" "$owner_pid" "$owner_harness" "$provider" "$classification" "$captured_pids") \ || die "could not prepare a durable handoff receipt before suspension" + receipt=$(receipt_publish "$draft" archive-requested archive_requested_at) \ + || die "could not publish a durable handoff receipt before suspension" if ! archive_out=$(paseo agent archive "$agent_id" --json 2>&1); then - rm -f "$draft" - die "Paseo soft archive failed: $archive_out" + fm_primary_receipt_append_state "$receipt" archive-failed archive_failed_at "$(fm_primary_now_utc)" || true + die "Paseo soft archive failed: $archive_out; receipt: $receipt" fi post=$(paseo_inspect "$agent_id" 2>/dev/null) || { - receipt=$(receipt_publish "$draft" suspend-incomplete suspend_incomplete_at) || true + fm_primary_receipt_append_state "$receipt" suspend-incomplete suspend_incomplete_at "$(fm_primary_now_utc)" || true die "Paseo archive returned success but the archived session could not be verified" } if [ "$(jq -r '.Archived' <<< "$post")" != true ]; then - receipt=$(receipt_publish "$draft" suspend-incomplete suspend_incomplete_at) || true + fm_primary_receipt_append_state "$receipt" suspend-incomplete suspend_incomplete_at "$(fm_primary_now_utc)" || true die "Paseo archive returned success but the target is not archived" fi if ! wait_tree_gone "$tree"; then remaining=$(remaining_pid_atoms "$FM_PRIMARY_REMAINING_PIDS") if [ -n "$remaining" ]; then - printf 'remaining_pids=%s\n' "$remaining" >> "$draft" \ + printf 'remaining_pids=%s\n' "$remaining" >> "$receipt" \ || die "owner process tree remained alive and the remaining process receipt could not be recorded" else die "owner process tree remained alive and the remaining process receipt could not be recorded" fi - receipt=$(receipt_publish "$draft" suspend-incomplete suspend_incomplete_at) \ + fm_primary_receipt_append_state "$receipt" suspend-incomplete suspend_incomplete_at "$(fm_primary_now_utc)" \ || die "owner process tree remained alive and the incomplete handoff receipt could not be published" die "owner process tree remained alive after soft archive ($FM_PRIMARY_REMAINING_PIDS); lock was left untouched; receipt: $receipt" fi - receipt=$(receipt_publish "$draft" suspended suspended_at) \ + fm_primary_receipt_append_state "$receipt" suspended suspended_at "$(fm_primary_now_utc)" \ || die "provider was suspended but the durable handoff receipt could not be published" release_claim_lock if ! "$SCRIPT_DIR/fm-lock.sh"; then @@ -605,7 +619,7 @@ restore() { fi state=$(fm_primary_receipt_last_value "$receipt" state 2>/dev/null || true) case "$state" in - active-successor|suspended|suspend-resolved|restore-failed) ;; + active-successor|suspended|suspend-resolved|restore-failed|archive-requested) ;; *) die "receipt state '$state' is not eligible for restore" ;; esac if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then diff --git a/docs/primary-session-handoff.md b/docs/primary-session-handoff.md index b92389704f..ca6ceed3c5 100644 --- a/docs/primary-session-handoff.md +++ b/docs/primary-session-handoff.md @@ -1,7 +1,7 @@ # Recoverable primary-session handoff Firstmate can transfer primary orchestration away from an idle local Paseo-hosted Claude or Codex session without weakening the live session lock or losing the provider transcript. -The transfer soft-archives the exact recoverable provider session, proves its captured process tree has stopped, acquires the unchanged numeric lock through the ordinary stale-owner path, and runs normal session start for the successor. +The transfer publishes a privacy-safe receipt before the provider archive boundary, soft-archives the exact recoverable provider session, proves its captured process tree has stopped, acquires the unchanged numeric lock through the ordinary stale-owner path, and runs normal session start for the successor. Use the command family directly or let the first mate follow the agent-only `primary-session-handoff` procedure: @@ -27,7 +27,8 @@ Only `idle` and `paused-rate-limited` are eligible for transfer. `waiting-on-captain`, `busy`, `wedged`, `unknown`, and `unsupported` refuse without provider lifecycle mutation. The privacy-safe receipt is stored under `data/primary-session-handoffs/`. -It contains session identifiers, provider and harness identity, classification, timestamps, a home fingerprint, and a process-identity hash, but no credentials, prompts, titles, permission descriptions, or transcript text. +It contains session identifiers, provider and harness identity, classification, timestamps, a home fingerprint, a captured pid set, and a process-identity hash, but no credentials, prompts, titles, permission descriptions, or transcript text. +An `archive-requested` receipt blocks ordinary stale-lock acquisition while any captured pid remains live, and restore accepts it only after the same process proof has cleared and Paseo verifies the provider is actually archived. Task metadata, worktrees, wake records, secondmate homes, and provider transcripts are outside the transaction and remain untouched. ## Restoration diff --git a/docs/verification/primary-session-handoff.md b/docs/verification/primary-session-handoff.md index a3ac90cbb3..e64f14defc 100644 --- a/docs/verification/primary-session-handoff.md +++ b/docs/verification/primary-session-handoff.md @@ -9,7 +9,7 @@ Operator behavior, supported combinations, and current limits remain in [`../pri The missing primitive was reproduced on 2026-07-29 with Paseo 0.1.104 and Claude Code 2.1.219. A live but idle credit-exhausted provider still correctly owned Firstmate's numeric lock, so another primary was refused even though a recoverable soft archive could safely stop that provider. -The manual incident recovery established the shipped ordering: prove visible-session ownership and idle safety, soft-archive the exact provider, prove its process tree has exited, use ordinary stale-lock acquisition, and require the restored provider to reacquire through normal session start. +The manual incident recovery established the shipped ordering: prove visible-session ownership and idle safety, publish a privacy-safe recovery receipt, soft-archive the exact provider, prove its process tree has exited, use ordinary stale-lock acquisition, and require the restored provider to reacquire through normal session start. No live provider lifecycle command is part of repository verification. All suspend, reload, quota, permission, child-agent, and process-tree behavior is exercised through an isolated disposable Firstmate home and fake Paseo executable. @@ -22,12 +22,13 @@ Run: bin/fm-test-run.sh tests/fm-primary-session.test.sh ``` -The suite covers external-session and process-identity owner mismatch, busy and pending-action refusal, unknown and wedged classification, attached-child refusal, provider suspend failure, a surviving owner pid, successful stale-lock takeover, concurrent attempts, restore refusal under a live successor, restored normal lock reacquisition, fleet-state preservation, and the privacy-safe read-only scan. +The suite covers external-session and process-identity owner mismatch, busy and pending-action refusal, unknown and wedged classification, attached-child refusal, provider suspend failure, a surviving owner pid, post-archive receipt write failure recovery, successful stale-lock takeover, concurrent attempts, restore refusal under a live successor, restored normal lock reacquisition, fleet-state preservation, and the privacy-safe read-only scan. The expected terminal lines include: ```text ok - primary-session: concurrent takeover attempts admit one suspension and one successor +ok - primary-session: post-archive receipt write failure preserves an explicit restore path ok - primary-session: archived provider reload remains recoverable and reacquires through normal session start ok - primary-session: fleet-wide captain-action scan is read-only and omits prompt/title prose ``` diff --git a/tests/fm-primary-session.test.sh b/tests/fm-primary-session.test.sh index 6baa31ecc7..1bfd325998 100755 --- a/tests/fm-primary-session.test.sh +++ b/tests/fm-primary-session.test.sh @@ -117,6 +117,9 @@ case "$cmd1 $cmd2" in owner=$(cat "$home/owner-pid") kill "$owner" 2>/dev/null || true fi + if [ -e "$home/chmod-receipt-after-archive" ]; then + chmod 400 "$FM_HOME"/data/primary-session-handoffs/*.receipt + fi jq -cn --arg id "$id" '{agentId:$id,status:"archived"}' ;; "agent reload") @@ -448,9 +451,9 @@ test_suspend_failure_and_live_tree_refuse() { assert_contains "$RUN_OUT" "soft archive failed" "suspend failure lacked a typed refusal" kill -0 "$OWNER_PID" 2>/dev/null || fail "suspend failure stopped the owner" [ "$(cat "$W_ROOT/state/.lock")" = "$OWNER_PID" ] || fail "suspend failure changed the lock" - [ ! -d "$W_ROOT/data/primary-session-handoffs" ] \ - || [ -z "$(find "$W_ROOT/data/primary-session-handoffs" -name '*.receipt' -print -quit)" ] \ - || fail "suspend failure published a completed receipt" + receipt=$(only_receipt) || fail "suspend failure did not retain its fail-closed receipt" + [ "$(record_last_value "$receipt" state)" = archive-failed ] \ + || fail "suspend failure receipt did not record archive-failed" new_world tree-alive write_agent "$AGENT_A" @@ -489,6 +492,29 @@ test_suspend_failure_and_live_tree_refuse() { pass "primary-session: suspend failure and surviving owner process tree block stale-lock acquisition until resolved" } +test_post_archive_receipt_write_failure_remains_restorable() { + new_world post-archive-receipt-failure + write_agent "$AGENT_A" + : > "$W_PASEO/chmod-receipt-after-archive" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -ne 0 ] || fail "post-archive receipt write failure unexpectedly completed takeover" + assert_contains "$RUN_OUT" "provider was suspended but the durable handoff receipt could not be published" \ + "post-archive receipt write failure lacked a typed refusal" + receipt=$(only_receipt) || fail "post-archive receipt failure did not leave a restore-eligible receipt" + [ "$(record_last_value "$receipt" state)" = archive-requested ] \ + || fail "post-archive receipt failure lost the pre-archive receipt state" + [ -e "$W_PASEO/archived-$AGENT_A" ] || fail "post-archive receipt failure did not archive the provider" + chmod 600 "$receipt" + receipt_id=$(record_last_value "$receipt" receipt_id) + : > "$W_PASEO/reload-spawn" + run_restore "$receipt_id" + [ "$RUN_RC" -eq 0 ] || fail "restore could not use the pre-archive receipt after publication recovered: $RUN_OUT" + assert_contains "$RUN_OUT" "restore requested" \ + "restore did not proceed from the pre-archive receipt" + pass "primary-session: post-archive receipt write failure preserves an explicit restore path" +} + test_lock_publish_failure_rolls_back_own_claim() { new_world publish-failure mkdir "$W_ROOT/state/.lock.owner" @@ -660,6 +686,7 @@ test_owner_mismatch_refuses_without_archive test_busy_pending_and_unknown_refuse test_wedged_and_attached_child_refuse test_suspend_failure_and_live_tree_refuse +test_post_archive_receipt_write_failure_remains_restorable test_lock_publish_failure_rolls_back_own_claim test_successful_takeover_preserves_fleet_and_records_receipt test_rate_limited_takeover_is_distinct_and_recoverable From 703947a67e885f6578c4788c2df0b21abd62a597 Mon Sep 17 00:00:00 2001 From: tmad4000 Date: Wed, 29 Jul 2026 03:21:13 -0700 Subject: [PATCH 5/8] no-mistakes(test): Captain, fix watcher restart and runtime gates --- bin/fm-watch-arm.sh | 10 ++++++++-- tests/fm-calm-pi-extension.test.sh | 30 ++++++++++++++++++++++++++++++ tests/fm-kimi-harness.test.sh | 1 + tests/fm-watcher-lock.test.sh | 11 +++++++++-- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index 3c2df49c89..62841a95a9 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -349,12 +349,18 @@ if [ "$mode" = restart ]; then clear_stale_recorded_watcher_lock fi fi + if healthy_watcher; then + cycle_mark_predecessor_successor "attached:$HEALTHY_PID" + cycle_begin "$HEALTHY_PID" attached + report_attached + attach_and_wait "$HEALTHY_PID" + exit $? + fi fi # If a genuinely live+fresh watcher already holds the lock, do not start a second # one - attach to that cycle and wait until it ends so the harness notify fires -# then, not as an immediate empty wake. (--restart skips this: it just stopped -# this home's watcher and wants a fresh one.) +# then, not as an immediate empty wake. if [ "$mode" = arm ] && healthy_watcher; then cycle_mark_predecessor_successor "attached:$HEALTHY_PID" cycle_begin "$HEALTHY_PID" attached diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 46b945e23f..159b1fb25d 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -27,6 +27,33 @@ record_pi_version_evidence() { [ -n "$version" ] || fail "$context could not determine the installed Pi version" } +pi_version_at_least() { + local version=$1 minimum=$2 v_major v_minor v_patch m_major m_minor m_patch + IFS=. read -r v_major v_minor v_patch <= 0.81.1 (found $version)" + return 0 + fi + return 1 +} + cleanup() { if command -v tmux >/dev/null 2>&1; then tmux -L "$TMUX_SOCKET" kill-server 2>/dev/null || true @@ -1067,6 +1094,7 @@ test_operational_followup_turn_e2e() { fi version=$(pi --version 2>/dev/null || true) record_pi_version_evidence "$version" "Pi operational follow-up E2E" + skip_old_pi_calm_e2e "$version" "Pi operational follow-up E2E" && return 0 project="$TMP_ROOT/followup-project" home="$TMP_ROOT/followup-home" @@ -1420,6 +1448,7 @@ test_hidden_block_geometry_e2e() { fi version=$(pi --version 2>/dev/null || true) record_pi_version_evidence "$version" "Pi Calm hidden-block geometry E2E" + skip_old_pi_calm_e2e "$version" "Pi Calm hidden-block geometry E2E" && return 0 project="$TMP_ROOT/geometry-project" home="$TMP_ROOT/geometry-home" @@ -1660,6 +1689,7 @@ test_interactive_terminal_e2e() { fi version=$(pi --version 2>/dev/null || true) record_pi_version_evidence "$version" "Pi calm interactive E2E" + skip_old_pi_calm_e2e "$version" "Pi calm interactive E2E" && return 0 project="$TMP_ROOT/e2e-project" config="$TMP_ROOT/e2e-config" diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 8ac5922ec5..1dc552946f 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -555,6 +555,7 @@ test_kimi_session_lock_identity() { cat > "$fakebin/ps" <<'SH' #!/usr/bin/env bash case "$*" in + *"lstart="*"command="*) printf '%s\n' 'Wed Jul 29 10:00:00 2026 kimi'; exit 0 ;; *"comm="*) printf '%s\n' '/opt/kimi/bin/kimi'; exit 0 ;; *"args="*) printf '%s\n' 'kimi'; exit 0 ;; esac diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index e741ec21e8..95f3436fea 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -438,14 +438,21 @@ test_watch_restart_rejects_reused_pid() { } test_watch_restart_attaches_to_healthy_peer() { - local dir state fakebin out peer identity armpid status i + local dir state fakebin out ready peer identity armpid status i dir=$(make_case restart-healthy-peer) state="$dir/state" fakebin="$dir/fakebin" out="$dir/restart.out" + ready="$dir/peer-ready" mark_pr_check_migration_complete "$state" - node -e 'process.on("SIGTERM", () => {}); setTimeout(() => {}, 300000)' & + node -e 'process.on("SIGTERM", () => {}); require("fs").writeFileSync(process.argv[1], "ready\n"); setTimeout(() => {}, 300000)' "$ready" & peer=$! + i=0 + while [ "$i" -lt 80 ] && [ ! -s "$ready" ]; do + sleep 0.05 + i=$((i + 1)) + done + [ -s "$ready" ] || fail "TERM-resistant peer did not publish readiness" identity=$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$peer") || fail "could not identify peer pid" mkdir "$state/.watch.lock" printf '%s\n' "$peer" > "$state/.watch.lock/pid" From 0a0c42452c3b9ae8becca6c83c2ffc3c3a4f7628 Mon Sep 17 00:00:00 2001 From: tmad4000 Date: Wed, 29 Jul 2026 03:34:44 -0700 Subject: [PATCH 6/8] no-mistakes(document): Captain: sync primary handoff docs --- AGENTS.md | 3 +++ README.md | 2 +- docs/architecture.md | 10 ++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2cf1bad41e..659a96938d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,8 +82,11 @@ data/ personal fleet records; LOCAL, gitignored as a whole secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6) /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate /report.md scout task deliverable, written by the crewmate; survives teardown + primary-session-handoffs/ privacy-safe takeover and restore receipts created by bin/fm-primary-session.sh projects/ cloned repos; gitignored; READ-ONLY for you state/ volatile runtime signals; gitignored + .lock numeric primary session lock; remains the sole orchestration authority + .lock.owner replaceable primary identity descriptor published after verified lock acquisition .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown diff --git a/README.md b/README.md index 64e84f874b..29073d2b4f 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ It routes each request to a crewmate in its own session endpoint and git worktre Optional secondmates extend this to persistent second mates, dispatch profiles let you steer which harness handles which task, and an opt-in X mode lets the same fleet answer public mentions. `codex-app` is not a runtime backend yet; [docs/codex-app-backend.md](docs/codex-app-backend.md) owns the Codex App boundary. -Full architecture - the supervision engine, worktree isolation, secondmates, dispatch profiles, project modes, optional X mode, fleet sync, and self-update - is in [docs/architecture.md](docs/architecture.md). +Full architecture - the supervision engine, recoverable primary handoff, worktree isolation, secondmates, dispatch profiles, project modes, optional X mode, fleet sync, and self-update - is in [docs/architecture.md](docs/architecture.md). ## Built-in skills diff --git a/docs/architecture.md b/docs/architecture.md index d1bcb83c56..94d167b064 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,6 +69,16 @@ It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns t On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work is in flight and no identity-matched watcher lock with a fresh beacon is live, direct Stop hooks block and passive turn-end hooks force one bounded follow-up. The guard covers the main primary and genuinely marked secondmate homes, exempts child crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md). +## Primary-session handoff + +The session lock is the authority for exactly one mutable first mate, while `state/.lock.owner` is a recoverable identity descriptor published after ordinary lock acquisition. +`bin/fm-primary-session.sh` can transfer that authority away from an idle or quota-paused local Paseo-hosted Claude or Codex primary by proving the visible Paseo session owns the current numeric lock, writing a privacy-safe receipt under `data/primary-session-handoffs/`, soft-archiving the provider, verifying the captured owner process tree has exited, and then using the same stale-lock path as normal session start. +Restore is explicit and remains separate from lock authority: the archived provider can be reloaded from its receipt only after the successor is no longer live, and that provider must run the native session-start nudge and reacquire the normal lock before any mutation. +Unsupported hosts and providers, child agents, captain-action markers, pending permissions, busy turns, wedges, unknown evidence, and incomplete suspensions all refuse without deleting or weakening a live lock. +The operator contract and supported-combination table live in [primary-session-handoff.md](primary-session-handoff.md), with deterministic fake-provider coverage recorded in [verification/primary-session-handoff.md](verification/primary-session-handoff.md). + +## Away-mode supervision + A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh`, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash. The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs, declared-external-wait vocabulary, and status-scan primitives. Terminal verbs remain captain-relevant, while a nonterminal progress verb cannot become terminal merely because its prose contains a legacy free-text token such as `merged`; bare legacy free-text lines remain compatible. From 10e603b1ef8257d6307a64c35434b6a22998fc21 Mon Sep 17 00:00:00 2001 From: tmad4000 Date: Wed, 29 Jul 2026 03:46:29 -0700 Subject: [PATCH 7/8] no-mistakes(review): Captain, fail-close takeover revalidation and quota --- bin/fm-primary-session.sh | 44 ++++++++++++++++++++++++++++-- tests/fm-primary-session.test.sh | 47 +++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/bin/fm-primary-session.sh b/bin/fm-primary-session.sh index 75e8f18e0e..2a83f97687 100755 --- a/bin/fm-primary-session.sh +++ b/bin/fm-primary-session.sh @@ -113,7 +113,7 @@ iso_epoch() { } quota_classification() { - local provider=$1 quota provider_count state_ok blocked + local provider=$1 quota provider_count state_ok quota_schema_ok blocked quota=$(quota-axi --provider "$provider" --json 2>/dev/null) || return 1 provider_count=$(jq --arg provider "$provider" '[.providers[]? | select(.provider == $provider)] | length' <<< "$quota") || return 1 [ "$provider_count" = 1 ] || return 1 @@ -122,11 +122,18 @@ quota_classification() { | (.state.status == "fresh" and .state.stale == false) ' <<< "$quota") || return 1 [ "$state_ok" = true ] || return 1 + quota_schema_ok=$(jq -r --arg provider "$provider" ' + [.providers[]? | select(.provider == $provider)][0] + | (.windows | type) == "array" + and ([.windows[] | select((.kind == "session") or (.kind == "weekly"))] | length) > 0 + and all(.windows[] | select((.kind == "session") or (.kind == "weekly")); (.percentRemaining | type) == "number") + ' <<< "$quota") || return 1 + [ "$quota_schema_ok" = true ] || return 1 blocked=$(jq -r --arg provider "$provider" ' [.providers[]? | select(.provider == $provider)][0] | any(.windows[]?; ((.kind == "session") or (.kind == "weekly")) - and ((.percentRemaining | numbers) <= 0)) + and (.percentRemaining <= 0)) ' <<< "$quota") || return 1 if [ "$blocked" = true ]; then printf 'rate-limited\n' @@ -485,6 +492,28 @@ receipt_publish() { printf '%s\n' "$final" } +assert_takeover_still_eligible() { + local owner_pid=$1 agent_id=$2 owner_harness=$3 expected_classification=$4 classification reason + fm_harness_pid_alive "$owner_pid" || { + printf 'lock owner exited before archive\n' + return 1 + } + [ "$(fm_harness_pid_kind "$owner_pid" 2>/dev/null || true)" = "$owner_harness" ] \ + || { + printf 'lock-owner harness changed before archive\n' + return 1 + } + if ! prove_lock_owner "$agent_id" "$owner_pid" "$owner_harness" 2>/dev/null; then + printf 'lock-owner identity changed before archive\n' + return 1 + fi + IFS='|' read -r classification reason <<< "$(classify_target "$owner_pid" "$agent_id" "$owner_harness")" + [ "$classification" = "$expected_classification" ] || { + printf 'target safety changed before archive: %s: %s\n' "$classification" "$reason" + return 1 + } +} + acquire_claim_lock() { # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" @@ -501,7 +530,7 @@ release_claim_lock() { takeover() { local agent_id=$1 new_owner_pid owner_pid owner_harness classification reason inspect provider - local tree captured_pids draft archive_out post receipt new_lock_pid now remaining rc=0 + local tree captured_pids draft archive_out post receipt new_lock_pid now remaining recheck_reason rc=0 fm_primary_atom_valid "$agent_id" || die "invalid Paseo agent id" require_takeover_tools primary_scope_required @@ -533,8 +562,17 @@ takeover() { [ -n "$captured_pids" ] || die "captured process tree had no restorable process evidence" draft=$(receipt_prepare "$agent_id" "$owner_pid" "$owner_harness" "$provider" "$classification" "$captured_pids") \ || die "could not prepare a durable handoff receipt before suspension" + if ! recheck_reason=$(assert_takeover_still_eligible "$owner_pid" "$agent_id" "$owner_harness" "$classification"); then + rm -f "$draft" + die "takeover refused: $recheck_reason" + fi receipt=$(receipt_publish "$draft" archive-requested archive_requested_at) \ || die "could not publish a durable handoff receipt before suspension" + if ! recheck_reason=$(assert_takeover_still_eligible "$owner_pid" "$agent_id" "$owner_harness" "$classification"); then + fm_primary_receipt_append_state "$receipt" archive-aborted archive_aborted_at "$(fm_primary_now_utc)" \ + || die "target safety changed before archive and the aborted handoff receipt could not be published: $receipt" + die "takeover refused: $recheck_reason; receipt: $receipt" + fi if ! archive_out=$(paseo agent archive "$agent_id" --json 2>&1); then fm_primary_receipt_append_state "$receipt" archive-failed archive_failed_at "$(fm_primary_now_utc)" || true die "Paseo soft archive failed: $archive_out; receipt: $receipt" diff --git a/tests/fm-primary-session.test.sh b/tests/fm-primary-session.test.sh index 1bfd325998..ac73832167 100755 --- a/tests/fm-primary-session.test.sh +++ b/tests/fm-primary-session.test.sh @@ -39,14 +39,23 @@ cmd1=${1:-} cmd2=${2:-} inspect_agent() { - local id=$1 file archived=false status provider cwd pending parent persistence + local id=$1 file archived=false status provider cwd pending parent persistence count_file count file=$(resolve_agent_file "$id") || exit 4 + mkdir -p "$home/inspect-counts" + count_file="$home/inspect-counts/$id" + count=0 + [ ! -s "$count_file" ] || count=$(cat "$count_file") + count=$(( count + 1 )) + printf '%s\n' "$count" > "$count_file" [ ! -e "$home/archived-$id" ] || archived=true status=$(jq -r '.lastStatus' "$file") [ "$archived" = false ] || status=closed provider=$(jq -r '.provider' "$file") cwd=$(jq -r '.cwd' "$file") pending=$(jq -r '.testPending // 0' "$file") + if [ -e "$home/race-pending-after-receipt" ] && [ "$count" -ge 4 ]; then + pending=1 + fi parent=$(jq -r '.testParent // empty' "$file") persistence=$(jq -r '.testPersistence // true' "$file") jq -cn \ @@ -162,6 +171,7 @@ remaining=100 [ ! -e "${PASEO_HOME:?}/quota-blocked" ] || remaining=0 state=fresh [ ! -e "$PASEO_HOME/quota-unknown" ] || state=error +[ ! -e "$PASEO_HOME/quota-malformed" ] || remaining='"unknown"' jq -cn \ --arg provider "$provider" \ --arg state "$state" \ @@ -563,6 +573,39 @@ test_rate_limited_takeover_is_distinct_and_recoverable() { pass "primary-session: fresh exhausted quota evidence produces a distinct recoverable pause" } +test_malformed_quota_refuses_without_archive() { + new_world malformed-quota + write_agent "$AGENT_A" + : > "$W_PASEO/quota-malformed" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -ne 0 ] || fail "malformed quota evidence unexpectedly took over" + assert_contains "$RUN_OUT" "fresh provider quota evidence is unavailable" \ + "malformed quota did not fail closed as unknown" + [ ! -e "$W_PASEO/calls.log" ] || fail "malformed quota invoked Paseo archive" + kill -0 "$OWNER_PID" 2>/dev/null || fail "malformed quota stopped the owner" + [ "$(cat "$W_ROOT/state/.lock")" = "$OWNER_PID" ] || fail "malformed quota changed the lock" + pass "primary-session: malformed quota evidence fails closed before archive" +} + +test_takeover_revalidates_after_receipt_publication() { + new_world revalidation-race + write_agent "$AGENT_A" + : > "$W_PASEO/race-pending-after-receipt" + start_owner "$AGENT_A" + run_takeover "$AGENT_A" + [ "$RUN_RC" -ne 0 ] || fail "stale post-receipt eligibility unexpectedly took over" + assert_contains "$RUN_OUT" "target safety changed before archive" \ + "post-receipt revalidation did not report the changed safety state" + [ ! -e "$W_PASEO/calls.log" ] || fail "post-receipt revalidation invoked Paseo archive" + receipt=$(only_receipt) || fail "post-receipt revalidation did not publish a recoverable receipt" + [ "$(record_last_value "$receipt" state)" = archive-aborted ] \ + || fail "post-receipt revalidation did not retire the prepared receipt" + kill -0 "$OWNER_PID" 2>/dev/null || fail "post-receipt revalidation stopped the owner" + [ "$(cat "$W_ROOT/state/.lock")" = "$OWNER_PID" ] || fail "post-receipt revalidation changed the lock" + pass "primary-session: takeover revalidates safety after receipt publication" +} + test_concurrent_takeovers_admit_one_successor() { local rc1 rc2 successes archive_count new_world concurrent @@ -690,6 +733,8 @@ test_post_archive_receipt_write_failure_remains_restorable test_lock_publish_failure_rolls_back_own_claim test_successful_takeover_preserves_fleet_and_records_receipt test_rate_limited_takeover_is_distinct_and_recoverable +test_malformed_quota_refuses_without_archive +test_takeover_revalidates_after_receipt_publication test_concurrent_takeovers_admit_one_successor test_restore_refuses_live_successor test_recoverable_happy_path_reacquires_normally From a97bf1b096bf5df8aa27c73e4f4b980ae2b28fe6 Mon Sep 17 00:00:00 2001 From: tmad4000 Date: Wed, 29 Jul 2026 03:56:32 -0700 Subject: [PATCH 8/8] no-mistakes(document): Sync primary handoff docs --- docs/configuration.md | 3 +++ docs/primary-session-handoff.md | 3 ++- docs/scripts.md | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8432959ca9..0580f1ea51 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -413,6 +413,9 @@ FMX_X_THREAD_MAX=25 # maximum messages in one auto-split reply thread FMX_FOLLOWUP_MAX_AGE_SECS=604800 # local window for posting X-mode completion follow-ups (7 days) FMX_FOLLOWUP_MAX_COUNT=3 # local cap on X-mode completion follow-ups per linked mention FM_LOCK_STALE_AFTER=2 # seconds before dead-pid lock records can be reclaimed; mid-acquire locks keep at least 2s grace +FM_PRIMARY_WEDGE_SECS=600 # seconds of no structured activity before a running primary-provider handoff target is classified as wedged +FM_PRIMARY_OWNER_EXIT_TIMEOUT=15 # seconds fm-primary-session.sh waits for the archived provider's captured owner process tree to exit +FM_PRIMARY_RELOAD_TIMEOUT=10 # seconds fm-primary-session.sh waits for a restored archived provider to become visible again FM_GUARD_GRACE=300 # seconds before guard warnings, arm health checks, and the primary turn-end guard treat a watcher beacon as stale FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=800 # milliseconds the --claude turn-end guard waits for the Stop auto-arm's claim, health, or fresh rewake epoch before re-blocking FM_CLAUDE_AUTOARM_EPOCH_FRESH=15 # seconds a recorded auto-arm rewake outcome counts as this event epoch's owned recovery diff --git a/docs/primary-session-handoff.md b/docs/primary-session-handoff.md index ca6ceed3c5..c4ce3d7f75 100644 --- a/docs/primary-session-handoff.md +++ b/docs/primary-session-handoff.md @@ -28,7 +28,8 @@ Only `idle` and `paused-rate-limited` are eligible for transfer. The privacy-safe receipt is stored under `data/primary-session-handoffs/`. It contains session identifiers, provider and harness identity, classification, timestamps, a home fingerprint, a captured pid set, and a process-identity hash, but no credentials, prompts, titles, permission descriptions, or transcript text. -An `archive-requested` receipt blocks ordinary stale-lock acquisition while any captured pid remains live, and restore accepts it only after the same process proof has cleared and Paseo verifies the provider is actually archived. +An `archive-requested` or `suspend-incomplete` receipt blocks ordinary stale-lock acquisition while any captured pid remains live. +Restore accepts only restore-eligible receipt states after the same process proof has cleared and Paseo verifies the provider is actually archived. Task metadata, worktrees, wake records, secondmate homes, and provider transcripts are outside the transaction and remain untouched. ## Restoration diff --git a/docs/scripts.md b/docs/scripts.md index 0e61d16b89..fdf3ddb590 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -28,7 +28,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` symlink, and the canonical self-governance section | | `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and stale watcher liveness | | `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks | -| `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk and holder liveness) for fm-lock.sh and the Claude Stop auto-arm | +| `fm-session-lock-lib.sh` | Shared session-lock harness identity for `fm-lock.sh`, primary-session handoff, and Stop auto-arm | | `fm-primary-session.sh` | Prove, classify, transfer, restore, and scan recoverable primary-provider sessions | | `fm-primary-session-lib.sh` | Shared primary owner-descriptor and privacy-safe handoff-receipt helpers | | `fm-claude-stop-autoarm.sh` | Claude Stop `asyncRewake` hook owning tokenless watcher continuity with single-flight exit-2 rewake (docs/watcher-continuity.md) |