From 06390cfbdf1dc06dcea7d477df5b4043347eba87 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 08:41:35 +0000 Subject: [PATCH 01/12] feat(first-mate-skill): one coordinator and one wait loop per effort The wait script now holds an flock on FIRST_MATE_DIR/.wait.lock keyed on the caller's PASEO_AGENT_ID. Starting is idempotent (exit 4 if you own the running loop, 5 if another agent does), and status, stop, and take-over subcommands are added. The skill treats a resumed or forked session as a non-coordinator until the user says otherwise, and never reports a loop as running without a status result from the same turn. scripts/test-wait-lock covers the lock offline with a stub detector and is run from scripts/verify. --- first-mate-skill/README.md | 7 +- first-mate-skill/TROUBLESHOOTING.md | 18 ++- ...-the-first-mate-skill-as-a-snapshot-kit.md | 8 ++ .../.agents/skills/paseo-first-mate/SKILL.md | 72 ++++++++-- .../assets/first-mate-wait.sh | 133 +++++++++++++++--- first-mate-skill/scripts/test-wait-lock | 106 ++++++++++++++ first-mate-skill/scripts/verify | 16 ++- 7 files changed, 322 insertions(+), 38 deletions(-) create mode 100755 first-mate-skill/scripts/test-wait-lock diff --git a/first-mate-skill/README.md b/first-mate-skill/README.md index ed46ffc..b8f6549 100644 --- a/first-mate-skill/README.md +++ b/first-mate-skill/README.md @@ -14,9 +14,10 @@ network hosts. ```text ~/.agents/skills/paseo-first-mate/ - SKILL.md the skill (version 1.2.0 at authorship) + SKILL.md the skill (version 1.3.0) assets/first-mate-check.py change detector (prints nothing when nothing changed) - assets/first-mate-wait.sh background wait loop around the detector + assets/first-mate-wait.sh background wait loop around the detector; one loop per + FIRST_MATE_DIR, owned by the starting agent (flock) ``` `~/.agents/skills` is the cross-agent skills root that the supported agents scan, @@ -53,7 +54,7 @@ The detector is a Python 3 script and the wait loop is bash. They call the `gh` and `paseo` CLIs when the agent runs them. This kit installs none of `python3`, `gh`, or `paseo` and does not allow-list `api.github.com`; kits cannot declare dependencies yet, so those must come from the sandbox image, its other kits, and -secret bindings. A missing tool is reported rather than hidden: the detector +secret bindings. The wait loop also needs `flock` (util-linux) and keys its lock on `PASEO_AGENT_ID`. Use `first-mate-wait.sh status` to see whether a loop is running and who owns it. A missing tool is reported rather than hidden: the detector prints an `ERROR` line naming the missing CLI, and the shell reports a missing `python3`. The detector only reads GitHub and Paseo state; its only writes are the state and watch files, and the directory that holds them, `FIRST_MATE_DIR` diff --git a/first-mate-skill/TROUBLESHOOTING.md b/first-mate-skill/TROUBLESHOOTING.md index 1667183..b6ac81c 100644 --- a/first-mate-skill/TROUBLESHOOTING.md +++ b/first-mate-skill/TROUBLESHOOTING.md @@ -37,12 +37,28 @@ That is the intended quiet path: once a baseline exists, it prints nothing when nothing changed, and the wait loop exits 3 at its cap. The very first run prints `BASELINE saved` (even with an empty watch list, because agents and permission prompts are always collected), so the wait loop returns once at first start; -restart it after that. If `paseo` is missing the baseline never settles (see the +start it again after that (check `status` first). If `paseo` is missing the baseline never settles (see the previous section), so the loop returns every time. Run the script with `--show` to see the full current snapshot, and `list` to see what is being watched. State and watch files live in `FIRST_MATE_DIR` (default `./tmp`), relative to where you run the script. +## Several wait loops, or "refusing: a loop is running" + +The wait script allows one loop per `FIRST_MATE_DIR`, owned by the agent whose +`PASEO_AGENT_ID` started it. A second start exits 4 (you own it) or 5 (another +agent owns it) and starts nothing. + +- `first-mate-wait.sh status` prints `none`, `mine ...`, or `other owner= ...`. +- `first-mate-wait.sh stop` stops your loop. `stop --force` stops another owner's. +- `first-mate-wait.sh take-over INTERVAL MAX` replaces another owner's loop. Use + it only when the user has said you are the coordinator. +- The lock is an `flock` on `FIRST_MATE_DIR/.wait.lock`, so it frees itself when the + loop dies. If you still see several loops, they are from an older copy of the + script or from different `FIRST_MATE_DIR` values (each directory has its own lock). +- A forked or resumed session inherits the handoff's claim that a loop is running. + That claim is not evidence; run `status`. + ## An edit to the skill does not show up in a running sandbox The kit is applied from a pinned SHA. Push the change, pin the new SHA, and diff --git a/first-mate-skill/docs/decisions/0001-ship-the-first-mate-skill-as-a-snapshot-kit.md b/first-mate-skill/docs/decisions/0001-ship-the-first-mate-skill-as-a-snapshot-kit.md index 5379c89..3f5181d 100644 --- a/first-mate-skill/docs/decisions/0001-ship-the-first-mate-skill-as-a-snapshot-kit.md +++ b/first-mate-skill/docs/decisions/0001-ship-the-first-mate-skill-as-a-snapshot-kit.md @@ -60,3 +60,11 @@ The skill is still being developed in a separate working copy. SHA. - The spec's `files[]` list and the `files/` tree must be kept equal by hand; `scripts/verify` enforces it, because msb materializes only listed records. + +## Update 2026-10-31: skill 1.3.0 + +The snapshot moved to skill version 1.3.0. `first-mate-wait.sh` now holds an +`flock` keyed on the caller's `PASEO_AGENT_ID`, and `scripts/test-wait-lock` +(run from `scripts/verify`) covers it offline with a stub detector. This changes +the shipped script, not the kit's shape: still files only, no commands, no +egress. The script now also requires `flock` from util-linux on the sandbox image. diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md index d79927f..407ebf4 100644 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md @@ -1,7 +1,7 @@ --- name: paseo-first-mate id: paseo-first-mate -version: "1.2.0" +version: "1.3.0" title: "Paseo First Mate" description: "Coordinate a multi-issue engineering effort through isolated Paseo workspaces, structured GitHub metadata, dependency-aware delivery, change-detecting follow-up, and a durable handoff." type: skill @@ -71,6 +71,8 @@ portability: chatgpt: false generic_llm: false changelog: + - version: "1.3.0" + changes: "One coordinator and one wait loop per effort: the wait script now holds an owner-keyed lock (PASEO_AGENT_ID) and has status, stop, and take-over; starting is idempotent and never stacks loops; a resumed or forked session is a non-coordinator until the user says otherwise; never report a loop as running without a status result from this turn; new troubleshooting rows." - version: "1.2.0" changes: "Add a change-detecting check and background wait loop (bundled assets) as the token-cheap default, alongside the Paseo heartbeat; add the end-the-turn rule for quiet wake-ups, standing ready-when-green grant, agent self-review and reuse rules, filing rule, host-run script hygiene, reviewer reply guidance, and new troubleshooting rows." - version: "1.1.0" @@ -144,6 +146,9 @@ Single-change and read-only work are covered by `avoid_when` above. privately and ask the user how to proceed. Do not file them as public issues. 11. Never merge, and never change PR draft/ready state, beyond what the user has explicitly granted. +12. One coordinator per effort. A resumed or forked session is a non-coordinator + until the user says it is the coordinator (see section 1). A non-coordinator + starts no wait loop and takes no action on the watched PRs and agents. ## Delivery Levels @@ -181,16 +186,24 @@ the handoff. ### 1. Adopt or Establish the Role -When resuming an existing effort: +When resuming an existing effort, or when this session is a fork of another: 1. Read the handoff. -2. Refresh authoritative remote state before reporting anything. -3. Verify that every control the handoff names actually exists on the accessible - daemon: wait loop, schedules, workspaces, agents. Record any control that is - missing or invisible; do not assume its state, resume it, or recreate it - without authorization. Reconcile workspaces against `paseo workspace ls`; - retained-workspace notes may be stale in a fresh sandbox. -4. Start the wait loop (section 8) if none is running. +2. Compare the coordinator ID in its Controls with your own `$PASEO_AGENT_ID`. + If they differ, or none is recorded, you are a non-coordinator: do steps 3 + onward read-only (use `first-mate-check.py --show`, which saves nothing), + start no loop, and ask the user whether you are taking over. A fork inherits + the handoff, including its claim that a loop is running; that claim is not + evidence. +3. Refresh authoritative remote state before reporting anything. +4. Verify that every control the handoff names actually exists on the accessible + daemon: wait loop (`first-mate-wait.sh status`), schedules, workspaces, + agents. Record any control that is missing or invisible; do not assume its + state, resume it, or recreate it without authorization. Reconcile workspaces + against `paseo workspace ls`; retained-workspace notes may be stale in a fresh + sandbox. +5. As the confirmed coordinator, ensure one wait loop is running (section 8). + Record your agent ID as coordinator in the handoff. ### 2. Establish the Mission @@ -395,7 +408,11 @@ one the user prefers and record the choice and any later change in the handoff: - **Background wait loop (default, cheapest).** A shell loop that runs the check on an interval and exits only when something changed. The harness notifies the - coordinator when it finishes, so waiting costs no model calls. + coordinator when it finishes, so waiting costs no model calls. There is at most + one loop per `FIRST_MATE_DIR`, and it is owned by the agent that started it. +- Prefer the heartbeat if the user plans to fork or resume sessions often. A + heartbeat is a named schedule, so it is one object that no fork can duplicate, + at the price of a model call per run. - **Paseo heartbeat.** A scheduled prompt that runs the check each time. Use `paseo heartbeat update --cron ...` to change its cadence (for example, hourly while nothing is moving, every 15 minutes once something changes). @@ -416,17 +433,29 @@ Bundled assets: `FIRST_MATE_DIR` (default `./tmp`, which must be ignored by git). - `assets/first-mate-wait.sh INTERVAL_MIN MAX_MIN` runs the check on an interval and exits as soon as it reports a change (exit 0, report printed) or at the cap - (exit 3, nothing printed). + (exit 3, nothing printed). It holds an `flock` on `FIRST_MATE_DIR/.wait.lock` + and records the owner (`$PASEO_AGENT_ID`), pid, and start time. The lock frees + itself when the process dies, so there is no stale pidfile. Starting while a + loop is running never starts a second one: exit 4 if you own it, exit 5 if + another agent does. Other forms: + - `status` prints `none`, `mine ...`, or `other owner= ...`. + - `stop` stops your own loop; `stop --force` stops another owner's. + - `take-over INTERVAL MAX` stops any running loop and starts yours. Use it only + after the user confirms you are the coordinator. Procedure: 1. Copy both assets next to each other into the coordinator worktree's ignored `tmp/`, register the repositories, and add the watch list. -2. For the wait loop, start the wait script as a **background** shell command. - For a heartbeat, have its prompt run `first-mate-check.py` once. +2. For the wait loop, run `first-mate-wait.sh status` first, then start the + script as a **background** shell command only if it says `none`. For a + heartbeat, have its prompt run `first-mate-check.py` once. 3. Use a 5-minute interval while work is in flight (an agent running, CI pending, or a review or live run awaited) and 15 minutes when quiet. Use a cap of about - two hours on the wait loop, and restart it after handling each notification. + two hours on the wait loop. After handling a notification, ensure a loop is + running (`status`, then start if `none`); never start one blindly. To change + the interval, `stop` and start again. Exit code 4 on a start means the loop + you wanted is already there, so do nothing more. 4. **Quiet wake-up rule:** if the check reports nothing, make no further tool calls and end the turn. Whether to say "no change" follows the user's current preference; record it in the handoff. Say it only when it is true: the check @@ -434,6 +463,13 @@ Procedure: 5. On a change, read the report, act only on concrete changes, update the handoff, report meaningful changes or blockers to the user, and start the next wait. 6. Prune merged and closed items from the watch list. +7. Report a loop as running only from a `status` result obtained this turn, + never from the handoff or from memory. + +The detector saves its snapshot when it reports. Two agents running it against +one `FIRST_MATE_DIR` split the events between them, and one never sees a change +the other consumed. Only the coordinator runs it in saving mode; everyone else +uses `--show`. The check resolves exact-head state, so stale cached PR check fields cannot mislead it. It cannot see GitHub notifications or review requests on PRs outside @@ -473,7 +509,8 @@ Updated: YYYY-MM-DD HH:MM UTC ## Wait Log ``` -- **Controls:** wait mechanism (loop or heartbeat), interval, and cap, schedule status (or "not visible"), +- **Controls:** coordinator agent ID (`$PASEO_AGENT_ID` of the owner), wait + mechanism (loop or heartbeat), interval, and cap, schedule status (or "not visible"), agent cap, coordinator and delegated models, permission setup per workspace. - **Current Coordination State:** the first section read after each wake-up. Keep the whole file near 200 lines. @@ -593,6 +630,8 @@ Before declaring coordination complete, verify: - Live results are observed, not simulated. - Required workspaces are retained; safe obsolete workspaces are archived. - The handoff reflects the final state and its log is cleared. +- Exactly one coordinator is recorded, and `first-mate-wait.sh status` was run + this turn before any statement about the loop. - The wait loop or heartbeat is stopped and its state file cleared when coordination ends. ## Troubleshooting @@ -607,6 +646,9 @@ Before declaring coordination complete, verify: | Branches diverge across worktrees | Compare commits and content; replay only missing reviewed commits with lease protection. | | Dependent PR conflicts after upstream merge | Preserve work, restack on current base, rerun focused checks, and refresh draft CI. | | Disk cleanup threatens blocked work | Retain dirty or dependency-blocked workspaces and record them in the handoff. | +| Several wait loops, or duplicate wake-ups | Run `first-mate-wait.sh status`. With the lock there is at most one loop; duplicates come from an older copy of the script or a different `FIRST_MATE_DIR`. Stop the extras, and check for a forked session acting as a second coordinator. | +| A forked session inherits "wait loop running" from the handoff | Treat it as unverified. The fork is a non-coordinator: no loop, read-only `--show`, and ask the user before taking over. | +| Start exits 5 (another owner holds the loop) | Do not force it. Ask the user who the coordinator is; use `take-over` only on their word. | | A quiet turn fills with empty tool calls | Stop. Run the check once and end the turn (section 8). | | Host-run script stalls with processes in state `T` | Job control froze the child. Use `timeout --foreground` and show live output. | | Reviewer's change request remains after the fix | Reply with the commits and wait for re-review; a push does not clear it. | diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh index 8f15388..86c9c1d 100755 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh @@ -1,24 +1,121 @@ #!/usr/bin/env bash -# First Mate wait loop. Runs first-mate-check.py (same directory) every INTERVAL minutes and -# returns as soon as it reports a change, so waiting costs no model tokens. +# First Mate wait loop: one loop per FIRST_MATE_DIR, owned by one agent. +# Runs first-mate-check.py (same directory) every INTERVAL minutes and returns as soon +# as it reports a change, so waiting costs no model tokens. # -# Usage: first-mate-wait.sh INTERVAL_MIN MAX_MIN -# exit 0 -> change found; the change report is printed (state already saved) -# exit 3 -> nothing changed within MAX_MIN (print nothing) -# Run it as a BACKGROUND shell command so the harness notifies on completion. +# Usage: +# first-mate-wait.sh INTERVAL_MIN MAX_MIN start a loop (refuses if one is running) +# first-mate-wait.sh status none | mine | other, with owner, pid, start +# first-mate-wait.sh stop [--force] stop your loop (--force: another owner's) +# first-mate-wait.sh take-over INTERVAL MAX stop any running loop, then start yours +# +# Exit codes of a start: +# 0 change found; the report is printed (state already saved) +# 3 nothing changed within MAX_MIN (prints nothing) +# 4 a loop is already running and you own it; nothing started +# 5 a loop is running and another owner holds it; nothing started +# 2 usage or environment error (for example, flock is missing) +# Run a start as a BACKGROUND shell command so the harness notifies on completion. # Use INTERVAL 5 while work is actively in flight, 15 when it is quiet. +# +# Ownership is the PASEO_AGENT_ID of the caller (override with FIRST_MATE_OWNER). The +# lock is an flock(2) on FIRST_MATE_DIR/.wait.lock, so it frees itself when the loop +# dies, including by SIGKILL; there is no pidfile to go stale. Needs flock (util-linux). set -uo pipefail -INTERVAL=${1:-15} -MAX=${2:-60} + HERE=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# State and watch files live in FIRST_MATE_DIR (default ./tmp); see first-mate-check.py. -deadline=$(( $(date +%s) + MAX * 60 )) -while :; do - out=$(python3 "$HERE/first-mate-check.py" 2>&1) - if [ -n "$out" ]; then - printf '%s\n' "$out" - exit 0 +DIR=${FIRST_MATE_DIR:-tmp} +mkdir -p -- "$DIR" || exit 2 +DIR=$(CDPATH='' cd -- "$DIR" && pwd) +export FIRST_MATE_DIR=$DIR +LOCK=$DIR/.wait.lock +INFO=$DIR/.wait.owner +ME=${FIRST_MATE_OWNER:-${PASEO_AGENT_ID:-local}} +export FIRST_MATE_OWNER=$ME + +command -v flock >/dev/null 2>&1 || { echo "first-mate-wait.sh needs flock (util-linux)" >&2; exit 2; } + +usage() { sed -n '2,/^set -uo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//' >&2; } +info_get() { sed -n "s/^$1=//p" "$INFO" 2>/dev/null | head -n 1; } + +# True when some process holds the lock. The subshell takes and releases it. +is_running() { if ( exec 8>"$LOCK"; flock -n 8 ); then return 1; else return 0; fi; } + +describe() { + if ! is_running; then echo "none"; return 0; fi + local owner kind + owner=$(info_get owner) + if [ "$owner" = "$ME" ]; then kind=mine; else kind=other; fi + echo "$kind owner=${owner:-unknown} pid=$(info_get pid) started=$(info_get started)" \ + "interval=$(info_get interval)m max=$(info_get max)m" +} + +cmd_stop() { + local force=0 owner pid i + [ "${1:-}" = "--force" ] && force=1 + if ! is_running; then echo "none running"; return 0; fi + owner=$(info_get owner); pid=$(info_get pid) + if [ "$owner" != "$ME" ] && [ "$force" -eq 0 ]; then + echo "refusing: the loop is owned by ${owner:-unknown}; use 'stop --force' or 'take-over'" >&2 + return 5 + fi + if [ -z "$pid" ] || ! tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q 'first-mate-wait.sh'; then + echo "cannot verify that pid '${pid:-none}' is the loop; not killing anything" >&2 + return 2 + fi + kill "$pid" + for i in $(seq 1 50); do + if ! is_running; then echo "stopped (was owned by ${owner:-unknown})"; return 0; fi + sleep 0.1 + done + echo "the loop did not stop; investigate pid $pid" >&2 + return 2 +} + +SLEEP_PID= +cleanup() { + [ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null + [ "$(info_get pid)" = "$$" ] && rm -f -- "$INFO" +} + +cmd_start() { + local interval=${1:-15} max=${2:-60} owner out deadline + case "$interval$max" in *[!0-9]*|'') usage; exit 2 ;; esac + exec 9>"$LOCK" + if ! flock -n 9; then + owner=$(info_get owner) + if [ "$owner" = "$ME" ]; then + echo "a loop is already running and you own it ($(describe)); not starting another" + exit 4 + fi + echo "refusing: a loop is running and ${owner:-another owner} holds it ($(describe))." \ + "Use 'take-over' only if the user agreed that you are the coordinator." >&2 + exit 5 fi - [ "$(date +%s)" -ge "$deadline" ] && exit 3 - sleep $(( INTERVAL * 60 )) -done + printf 'owner=%s\npid=%s\nstarted=%s\ninterval=%s\nmax=%s\n' \ + "$ME" "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$interval" "$max" > "$INFO" + trap cleanup EXIT + trap 'exit 143' TERM INT + deadline=$(( $(date +%s) + max * 60 )) + while :; do + # Children must not inherit the lock fd, or a killed loop would keep the lock. + out=$(python3 "$HERE/first-mate-check.py" 2>&1 9>&-) + if [ -n "$out" ]; then + printf '%s\n' "$out" + exit 0 + fi + [ "$(date +%s)" -ge "$deadline" ] && exit 3 + sleep $(( interval * 60 )) 9>&- & + SLEEP_PID=$! + wait "$SLEEP_PID" + done +} + +case "${1:-}" in + status) describe ;; + stop) shift; cmd_stop "$@" ;; + take-over) shift; cmd_stop --force || exit $?; cmd_start "$@" ;; + -h|--help) usage ;; + ''|[0-9]*) cmd_start "$@" ;; + *) usage; exit 2 ;; +esac diff --git a/first-mate-skill/scripts/test-wait-lock b/first-mate-skill/scripts/test-wait-lock new file mode 100755 index 0000000..16b4ff1 --- /dev/null +++ b/first-mate-skill/scripts/test-wait-lock @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# +# test-wait-lock — offline test of first-mate-wait.sh ownership and locking. +# Uses a stub detector, so it needs no gh, paseo, or network. Needs flock. +# +# Usage: first-mate-skill/scripts/test-wait-lock + +set -uo pipefail + +KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WAIT_SRC="$KIT_DIR/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh" + +if ! command -v flock >/dev/null 2>&1; then + echo "SKIP flock not installed" + exit 0 +fi + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/first-mate-wait-test.XXXXXX")" || exit 1 +mkdir -p "$WORK/bin" "$WORK/state" +cp "$WAIT_SRC" "$WORK/bin/first-mate-wait.sh" +# Stub detector: quiet until a "change" file appears, then prints once. +cat > "$WORK/bin/first-mate-check.py" <<'PY' +#!/usr/bin/env python3 +import os +p = os.path.join(os.environ["FIRST_MATE_DIR"], "change") +if os.path.exists(p): + print("CHANGED stub") +PY +chmod +x "$WORK/bin/first-mate-wait.sh" "$WORK/bin/first-mate-check.py" + +WAIT="$WORK/bin/first-mate-wait.sh" +export FIRST_MATE_DIR="$WORK/state" +pass=0 +fail=0 +check() { # name expected actual + if [ "$2" = "$3" ]; then + printf ' PASS %s\n' "$1"; pass=$((pass+1)) + else + printf ' FAIL %s (expected %q, got %q)\n' "$1" "$2" "$3"; fail=$((fail+1)) + fi +} +as() { local who=$1; shift; FIRST_MATE_OWNER=$who "$WAIT" "$@"; } +# For calls that must return at once; a mutated script would block here. +quick() { local who=$1; shift; FIRST_MATE_OWNER=$who timeout --foreground 5 "$WAIT" "$@"; } +cleanup() { + FIRST_MATE_OWNER=any "$WAIT" stop --force >/dev/null 2>&1 + # Only processes started from this test's own directory. + pkill -f "$WORK/bin/" 2>/dev/null + rm -rf "$WORK" +} +trap cleanup EXIT INT TERM + +echo "status with nothing running" +check "status is none" "none" "$(as alice status)" + +echo "start a loop as alice" +as alice 1 5 >"$WORK/alice.out" 2>&1 & +for _ in $(seq 1 50); do [ "$(as alice status)" != "none" ] && break; sleep 0.1; done +check "alice sees mine" "mine" "$(as alice status | cut -d' ' -f1)" +check "bob sees other" "other" "$(as bob status | cut -d' ' -f1)" + +echo "a second start never stacks" +quick alice 1 5 >/dev/null 2>&1; check "alice restart exits 4" "4" "$?" +quick bob 1 5 >/dev/null 2>&1; check "bob start exits 5" "5" "$?" +check "still exactly one loop" "1" "$(pgrep -fc "$WORK/bin/first-mate-wait.sh (take-over|[0-9])")" + +echo "stop respects ownership" +as bob stop >/dev/null 2>&1; check "bob cannot stop alice" "5" "$?" +check "alice loop still running" "mine" "$(as alice status | cut -d' ' -f1)" +as alice stop >/dev/null 2>&1; check "alice stops her loop" "0" "$?" +check "status is none after stop" "none" "$(as alice status)" +wait + +echo "a SIGKILLed loop frees the lock" +as alice 1 5 >/dev/null 2>&1 & +for _ in $(seq 1 50); do [ "$(as alice status)" != "none" ] && break; sleep 0.1; done +pid="$(sed -n 's/^pid=//p' "$FIRST_MATE_DIR/.wait.owner")" +kill -9 "$pid"; wait 2>/dev/null +sleep 0.3 +check "lock is free after kill -9" "none" "$(as bob status)" + +echo "take-over replaces another owner" +as alice 1 5 >/dev/null 2>&1 & +for _ in $(seq 1 50); do [ "$(as alice status)" != "none" ] && break; sleep 0.1; done +as bob take-over 1 5 >"$WORK/bob.out" 2>&1 & +for _ in $(seq 1 80); do [ "$(as bob status | cut -d' ' -f1)" = "mine" ] && break; sleep 0.1; done +check "bob now owns it" "mine" "$(as bob status | cut -d' ' -f1)" +check "exactly one loop after take-over" "1" "$(pgrep -fc "$WORK/bin/first-mate-wait.sh (take-over|[0-9])")" + +as bob stop >/dev/null 2>&1; wait 2>/dev/null + +echo "a change ends the loop with exit 0 and the report" +touch "$FIRST_MATE_DIR/change" +out="$(as alice 1 5 2>&1)"; rc=$? +check "exit 0 on change" "0" "$rc" +check "report is printed" "CHANGED stub" "$out" +check "lock released on exit" "none" "$(as alice status)" +rm -f "$FIRST_MATE_DIR/change" + +echo "the cap exits 3 and prints nothing" +out="$(as alice 1 0 2>&1)"; rc=$? +check "exit 3 at the cap" "3" "$rc" +check "nothing printed" "" "$out" + +printf '\n %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/first-mate-skill/scripts/verify b/first-mate-skill/scripts/verify index 7f4e8bc..9698777 100755 --- a/first-mate-skill/scripts/verify +++ b/first-mate-skill/scripts/verify @@ -119,7 +119,7 @@ else sed 's/^/ | /' "$WORK/sh.log" >&2 fi if command -v shellcheck >/dev/null 2>&1; then - if shellcheck --severity=warning "$WAIT_SH" "$0" >"$WORK/shellcheck.log" 2>&1; then + if shellcheck --severity=warning "$WAIT_SH" "$0" "$KIT_DIR/scripts/test-wait-lock" >"$WORK/shellcheck.log" 2>&1; then ok "shellcheck --severity=warning passes" else bad "shellcheck --severity=warning failed" @@ -129,6 +129,20 @@ else skip "shellcheck not installed (install it to run this gate)" fi +info "4b. Wait loop locking and ownership" +# One loop per FIRST_MATE_DIR, keyed on the owner. Uses a stub detector, so it +# needs no gh, paseo, or network. Skips itself when flock is missing. +if command -v flock >/dev/null 2>&1; then + if bash "$KIT_DIR/scripts/test-wait-lock" >"$WORK/waitlock.log" 2>&1; then + ok "first-mate-wait.sh locking and ownership test passes" + else + bad "first-mate-wait.sh locking and ownership test failed" + sed 's/^/ | /' "$WORK/waitlock.log" >&2 + fi +else + skip "flock not installed (wait-loop locking test not run)" +fi + info "5. Shipped detector has no baked-in watch targets" # `list` prints the watch list without calling GitHub or Paseo. It must come # back with no PRs, issues, or releases, so nothing is hard-coded to a repo. From 325ca7d9f142f49906a94658386cfd98c16c409b Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 08:42:35 +0000 Subject: [PATCH 02/12] feat(first-mate-skill): watch branches and workflows The detector can now watch a branch (each new commit) and a workflow (latest run on a branch: head, status, conclusion), alongside PRs, issues, and releases. A PR-only watch list misses work that lands from other sessions and a deploy that stays red after a merge. The skill says to always watch the default branch and the workflows the milestone depends on, and to reconcile open recommendations against any merge a branch report shows. --- .../.agents/skills/paseo-first-mate/SKILL.md | 17 +++- .../assets/first-mate-check.py | 81 ++++++++++++++++++- first-mate-skill/scripts/verify | 2 +- 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md index 407ebf4..9b8b893 100644 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md @@ -424,11 +424,13 @@ of calls. Run the check once, then end the turn. Bundled assets: - `assets/first-mate-check.py` queries the watched PRs (head, draft state, reviews, - inline comments, check results, merge state), issues, releases, other agents, - and pending permission prompts in parallel, compares them with a saved + inline comments, check results, merge state), issues, releases, **branches** + (each new commit), **workflows** (latest run on a branch: head, status, + conclusion), other agents, and pending permission prompts in parallel, compares them with a saved snapshot, and prints **nothing** when nothing changed. A transient API error keeps the previous value and is reported. Manage the watch list with `add`, - `remove`, `prune`, `repo`, and `list`; `--show` prints the snapshot without + `remove`, `prune`, `repo`, and `list` (for example `add branch kits@main`, + `add workflow kits:deploy.yml@main`); `--show` prints the snapshot without saving, and `--reset` forgets state. State and watch files live in `FIRST_MATE_DIR` (default `./tmp`, which must be ignored by git). - `assets/first-mate-wait.sh INTERVAL_MIN MAX_MIN` runs the check on an interval @@ -446,7 +448,11 @@ Bundled assets: Procedure: 1. Copy both assets next to each other into the coordinator worktree's ignored - `tmp/`, register the repositories, and add the watch list. + `tmp/`, register the repositories, and add the watch list. Always watch the + default branch of every repository in scope, and the deploy or release + workflows that the effort's milestone depends on. A merge you did not make + (another session, another person) and a pipeline that stays red after your + merge are both invisible to a PR-only watch list. 2. For the wait loop, run `first-mate-wait.sh status` first, then start the script as a **background** shell command only if it says `none`. For a heartbeat, have its prompt run `first-mate-check.py` once. @@ -462,6 +468,9 @@ Procedure: ran and reported nothing, never as a guess. 5. On a change, read the report, act only on concrete changes, update the handoff, report meaningful changes or blockers to the user, and start the next wait. + When a branch report shows a merge, reconcile before anything else: which + open recommendations, agents, and PRs does it supersede, conflict with, or + unblock? Another session may have fixed the problem differently. 6. Prune merged and closed items from the watch list. 7. Report a loop as running only from a `status` result obtained this turn, never from the handoff or from memory. diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py index 15f38f2..66ddd48 100755 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py @@ -10,7 +10,14 @@ What to watch lives in first-mate-watch.json, not in this script: {"repos": {"qs": "OWNER/repo", ...}, "prs": ["qs#611", ...], "issues": ["qs#503", ...], - "releases": ["qs", ...], "ignore_agents": ["First Mate"]} + "releases": ["qs", ...], + "branches": ["qs@main", ...], + "workflows": ["qs:deploy-dev.yml@main", ...], + "ignore_agents": ["First Mate"]} + +A watched branch reports each new commit on it (so work that lands from other +sessions is seen). A watched workflow reports the latest run on a branch: its +head, status, and conclusion (so a deploy that stays red after a merge is seen). Usage: first-mate-check.py compare to saved state, print changes, save @@ -18,7 +25,10 @@ first-mate-check.py --reset forget saved state (next run is a baseline) first-mate-check.py list print the watch list first-mate-check.py add pr qs#637 start watching (also: issue; also OWNER/repo#N) - first-mate-check.py remove pr qs#611 stop watching and forget its state + first-mate-check.py add branch qs@main watch new commits on a branch + first-mate-check.py add workflow qs:deploy-dev.yml@main + watch the latest run of a workflow on a branch + first-mate-check.py remove pr qs#611 stop watching and forget its state (also: issue, branch, workflow) first-mate-check.py repo qs OWNER/repo register a repo alias first-mate-check.py prune stop watching merged/closed PRs and closed issues """ @@ -40,9 +50,14 @@ "prs": [], "issues": [], "releases": [], + "branches": [], + "workflows": [], "ignore_agents": ["First Mate"], } +BRANCH_REF = re.compile(r"^(?P[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)?)@(?P.+)$") +WORKFLOW_REF = re.compile( + r"^(?P[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)?):(?P[^@:]+)@(?P.+)$") BAD_CONCLUSIONS = {"FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"} BAD_STATES = {"FAILURE", "ERROR"} REF = re.compile(r"^(?:(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+|[A-Za-z0-9_-]+)#)(?P\d+)$") @@ -76,6 +91,30 @@ def resolve(w, ref): return repo, int(m.group("num")) +def repo_of(w, alias): + repo = w["repos"].get(alias, alias if "/" in alias else None) + if not repo: + raise SystemExit(f"unknown repo alias '{alias}'; register it: repo {alias} OWNER/repo") + return repo + + +def parse_branch(w, ref): + m = BRANCH_REF.match(ref) + if not m: + raise SystemExit(f"bad branch reference '{ref}' (want alias@branch)") + repo = repo_of(w, m.group("alias")) + return repo, m.group("branch"), f"{short(w, repo)}@{m.group('branch')}" + + +def parse_workflow(w, ref): + m = WORKFLOW_REF.match(ref) + if not m: + raise SystemExit(f"bad workflow reference '{ref}' (want alias:workflow-file@branch)") + repo = repo_of(w, m.group("alias")) + wf, br = m.group("workflow"), m.group("branch") + return repo, wf, br, f"{short(w, repo)}:{wf}@{br}" + + def short(w, repo): for a, r in w["repos"].items(): if r == repo: @@ -134,6 +173,22 @@ def release_snapshot(repo): return gh_json(["release", "view", "--repo", repo, "--json", "tagName"])["tagName"] +def branch_snapshot(repo, branch): + d = gh_json(["api", f"repos/{repo}/commits/{branch}"]) + subject = (d.get("commit", {}).get("message") or "").splitlines()[:1] + return f"{d['sha'][:8]} {(subject[0] if subject else '')[:80]}" + + +def workflow_snapshot(repo, workflow, branch): + rows = gh_json([ + "run", "list", "--repo", repo, "--workflow", workflow, "--branch", branch, + "--limit", "1", "--json", "databaseId,headSha,status,conclusion"]) + if not rows: + return "no runs" + r = rows[0] + return f"run {r['databaseId']} head={r['headSha'][:8]} {r['status']} {r.get('conclusion') or '-'}" + + def agents_snapshot(ignore): rows = json.loads(run(["paseo", "ls", "--json"])) items = sorted( @@ -158,6 +213,12 @@ def collect(w): for alias in w["releases"]: repo = w["repos"].get(alias, alias) jobs[f"release {short(w, repo)}"] = (release_snapshot, repo) + for ref in w["branches"]: + repo, br, label = parse_branch(w, ref) + jobs[f"branch {label}"] = (branch_snapshot, repo, br) + for ref in w["workflows"]: + repo, wf, br, label = parse_workflow(w, ref) + jobs[f"workflow {label}"] = (workflow_snapshot, repo, wf, br) jobs["agents"] = (agents_snapshot, set(w["ignore_agents"])) jobs["permission prompts"] = (permits_snapshot,) results, errors = {}, {} @@ -188,6 +249,22 @@ def manage(w, argv): cmd = argv[0] if cmd == "list": print(json.dumps(w, indent=1)) + elif cmd in ("add", "remove") and len(argv) == 3 and argv[1] in ("branch", "workflow"): + kind = argv[1] + key = "branches" if kind == "branch" else "workflows" + label = parse_branch(w, argv[2])[2] if kind == "branch" else parse_workflow(w, argv[2])[3] + if cmd == "add": + if label not in w[key]: + w[key].append(label) + save_watch(w) + print(f"watching {kind} {label}") + else: + w[key] = [x for x in w[key] if x != label] + save_watch(w) + st = load_state() + st.pop(f"{kind} {label}", None) + save_state(st) + print(f"stopped watching {kind} {label}") elif cmd in ("add", "remove") and len(argv) == 3 and argv[1] in ("pr", "issue"): key = "prs" if argv[1] == "pr" else "issues" repo, n = resolve(w, argv[2]) diff --git a/first-mate-skill/scripts/verify b/first-mate-skill/scripts/verify index 9698777..e61200c 100755 --- a/first-mate-skill/scripts/verify +++ b/first-mate-skill/scripts/verify @@ -149,7 +149,7 @@ info "5. Shipped detector has no baked-in watch targets" if command -v "$PYTHON_BIN" >/dev/null 2>&1; then mkdir -p "$WORK/state" if FIRST_MATE_DIR="$WORK/state" "$PYTHON_BIN" "$CHECK_PY" list >"$WORK/list.out" 2>"$WORK/list.err" \ - && "$PYTHON_BIN" -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if not (d.get("repos") or d.get("prs") or d.get("issues") or d.get("releases")) else 1)' "$WORK/list.out"; then + && "$PYTHON_BIN" -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if not (d.get("repos") or d.get("prs") or d.get("issues") or d.get("releases") or d.get("branches") or d.get("workflows")) else 1)' "$WORK/list.out"; then ok "first-mate-check.py ships with an empty watch list" else bad "first-mate-check.py list failed or returned a non-empty watch list" From 551d4364a37369b8c90abf1e086df47cc8ecb7e3 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 08:42:51 +0000 Subject: [PATCH 03/12] feat(first-mate-skill): fresh bases and verified agent claims New workspaces are created from a freshly fetched origin/ with the base SHA recorded in the prompt and re-checked by the agent. Coordinator reports must verify agent claims about the baseline (fails on main, CI started, no conflicts) before repeating them, and must read a finish notice against the agent's status and the latest prompt sent. --- .../.agents/skills/paseo-first-mate/SKILL.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md index 9b8b893..e1a1bfa 100644 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md @@ -297,12 +297,30 @@ Reuse and self-review: it to commit, self-review, and report. - Identify agents and workspaces you did not create, leave them alone, do not count them against the cap, and mention them once. +- **Verify agent claims about the baseline before repeating them.** "Fails on + main", "pre-existing failure", "CI started", and "no conflicts" are claims about + state the agent may not be able to see (a stale base, a token that cannot read + check runs, a tool missing from its sandbox). Reproduce them on a clean base, + or read the exact-head check results yourself, and label what you could not + confirm. +- **Read a finish notice against the agent's status and your last prompt.** A + finished notice can be the end of an earlier turn, not the reply to your latest + follow-up. If the agent shows as running or idle with no new activity after your + prompt, say that you cannot tell yet; do not describe a result you have not + read. Permissions: auto-accept is usually a per-workspace UI toggle the CLI does not expose, so ask the user to set it after creating a workspace. A provider "build" mode may not suppress prompts; check pending permissions and the daemon log. Never approve a request unless the user instructs it. +Base of a new workspace: `git fetch` first and create the workspace from +`origin/` (or the exact head of the PR you depend on), never from +a local branch that may be stale. Record the base SHA, put it in the delegation +prompt, and tell the agent to confirm `git merge-base HEAD origin/` +equals it before its first edit. A stale base shows up later as a conflicting PR +and as false claims such as "this test already fails on main". + Branching from an in-flight PR: branch from its exact head, expect a rebase after it lands, and do not mark the new work ready first. If an SSH fetch fails host-key verification, fetch over authenticated HTTPS without altering remotes. From af4ec20446dd47f242951a7c0e6974e79fe733c0 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 08:43:03 +0000 Subject: [PATCH 04/12] feat(first-mate-skill): runtime acceptance signal, mutation proof, post-merge proof A green pipeline does not show the deployed thing runs, so the acceptance signal must be a named runtime observable, and changes that can only be proven by the first run after merge stay open until it is watched and observed. New guards need a mutation proof stating what the test does not cover. --- .../.agents/skills/paseo-first-mate/SKILL.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md index e1a1bfa..e06fd9d 100644 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md @@ -137,6 +137,10 @@ Single-change and read-only work are covered by `avoid_when` above. `ready-push` level (see Delivery Levels) is the only exception to waiting for CI before a PR is marked ready. 6. Never present mocked, offline, or simulated evidence as live end-to-end proof. + A green pipeline is not proof that the deployed thing runs: a deploy can + succeed while the application it created crash-loops. Name the runtime signal + up front (for example, the process count and a specific startup log line), and + keep the issue unverified until someone has observed that signal. 7. Label every claim as verified, hypothesis, or unverified. Fix the live acceptance criterion before implementation starts. 8. Never archive dirty, unpushed, unrecoverable, or intentionally retained work. @@ -332,6 +336,11 @@ required focused tests and agent-launched self-review; the draft/ready/merge gua (push levels only); and "if the result contradicts the premise, report it and do not fix it." +For a new guard, check, or test, also require a mutation proof: the agent reverts +the fix, shows the test fails, restores it, and says what the test does not cover. +A test that cannot fail gives false assurance, and a test that asserts only that a +string exists is not a behavior test. + Use the completion clause that matches the delivery level: ```text @@ -584,6 +593,14 @@ After upstream merges: - Close delivered issues through structured metadata. - Update the epic status and handoff. - Surface work that the merge unblocked. +- **Post-merge proof.** Some changes can only be proven by the first real run + after merge (a deploy, a release, a scheduled job). State that in the PR and the + handoff, add the run to the watch list (section 8), and write the pass criterion + as an observable (for example, "two consecutive runs plan zero changes on X" + or "the app shows 1/1 and logs its ready line"). Keep the item open until it is + observed. When it fails, read the failing step's log before reporting, and + separate "my change did not work" from "my change worked and exposed the next + failure". Do not declare the milestone complete until production wiring, downstream consumers, current-head CI, review, and live evidence agree. From 6ea9915d0b012b563755cd4fd34ff8ab693df07e Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 08:43:21 +0000 Subject: [PATCH 05/12] feat(first-mate-skill): repo PR standards win; pending decisions in the handoff The seven-section PR template is now only a fallback. The target repository's own PR standard (prefixes, closing keywords, attribution, sections) takes precedence and goes into every delegation prompt, including a newer standard the user points at. The handoff gains a Pending Decisions and Held Items section recording recommendations with their triggers, and records standing answers to routine permission prompts. --- .../.agents/skills/paseo-first-mate/SKILL.md | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md index e06fd9d..d04ae59 100644 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md @@ -408,7 +408,14 @@ Before significant pushes, ask the reviewer to inspect only the changed risk surface and named prior blockers. Broad reviews that keep inventing unrelated scope make delivery non-terminating. -PR bodies use plain language and stay concise: +Read the target repository's own PR standard first (its contributing guide, agent +instructions, and PR template). Where it conflicts with the template below, the +repository wins: follow its title and body prefixes, closing-keyword rules +(`Refs` versus `Closes`), attribution rules, and required sections, and put +them in every delegation prompt. When the user points at a newer or draft +standard, treat it as the target and bring open PR bodies in line with it. + +Without a repository standard, PR bodies use plain language and stay concise: ```markdown ## Summary @@ -542,6 +549,7 @@ Updated: YYYY-MM-DD HH:MM UTC ## Immediate Next Actions ## Workspace Safety ## Fast-Track Grants +## Pending Decisions and Held Items ## Wait Log ``` @@ -554,7 +562,16 @@ Updated: YYYY-MM-DD HH:MM UTC durable facts into the state sections, save a gitignored backup under `tmp/archive/`, then empty the log. - **Standing rules:** record every user process rule with its date: delivery - grants, ready-when-green, filing rule, self-review rule, agent reuse. + grants, ready-when-green, filing rule, self-review rule, agent reuse, and + standing answers to routine permission prompts (for example, "web search is + allowed for public questions"). Apply a recorded policy; ask about anything it + does not cover. +- **Pending Decisions and Held Items:** add this section to the template. For each + open question, record your recommendation, the options, and the trigger that + would make it urgent, so the user can ask "what did you think?" and get the + same answer after a compaction. Held security findings are listed by title only, + with a pointer to a private note outside the repository; the note's contents + and any attack detail stay out of the handoff and every public place. - **Prune stale detail** once superseded: old head SHAs, retention notes for workspaces that no longer exist, and debugging narratives. From 2b39abff046021fc3bc44931d795514ca9cb7289 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 08:43:35 +0000 Subject: [PATCH 06/12] feat(first-mate-skill): shape-only host reads, drafts for human threads, cleanup on merge Adds the remaining working rules: ask the user for shape-only output when reading live systems, show a draft and options before posting on a human's thread, cite the log line or file:line when naming a cause, report checks a sandbox could not run instead of skipping them, apply recorded permission policies, and retire an agent, workspace, watch entry and exact-match local branch when a PR merges. --- .../.agents/skills/paseo-first-mate/SKILL.md | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md index d04ae59..c61a532 100644 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md @@ -316,7 +316,11 @@ Reuse and self-review: Permissions: auto-accept is usually a per-workspace UI toggle the CLI does not expose, so ask the user to set it after creating a workspace. A provider "build" mode may not suppress prompts; check pending permissions and the daemon log. -Never approve a request unless the user instructs it. +Never approve a request unless the user instructs it. A standing policy the user +has recorded in the handoff (for example, routine web search for public +questions) counts as an instruction, within its stated scope. Some tools need a +specific response shape; if a plain "allow" fails with a validation error, retry +in the shape the error names and record the working form in the handoff. Base of a new workspace: `git fetch` first and create the workspace from `origin/` (or the exact head of the PR you depend on), never from @@ -594,6 +598,11 @@ Archive a workspace only when all conditions are true: Dirty files, untracked files, staged changes, unpushed commits, unavailable owners, or named retention are hard stops. +When a PR merges, retire what it owned in one pass: archive its idle agent and +workspace, drop it from the watch list, and delete the local branch only if it +matches the remote branch exactly. Do not touch agents or workspaces you did not +create. + ### 11. Close the Loop After upstream merges: @@ -628,6 +637,21 @@ consumers, current-head CI, review, and live evidence agree. PR, its docs, and results folders. If earlier runs do not cover the new check, say exactly what was logged, what it did not cover, and why the new run is needed. Results must be tied to the tested revision. +- **Read live systems with shape-only commands.** When you need facts from an + environment only the user can reach, ask for output that cannot carry secrets: + names, counts, and key lists, never values and never an environment dump. Give + one pasteable command that filters to that shape (for example, `jq` selecting + credential key names), and say what the output can and cannot show. +- **Drafts for human threads.** Before posting on a reviewer's or user's thread, + show the draft, give your recommendation and the options (post, change, + remove), and wait. Post with the repository's attribution prefix, and keep the + wording neutral about anything held under invariant 10. +- **Name the cause before blaming it.** A root-cause statement cites the log line + or `file:line` that locates it, and says which resource it concerns. Correct a + mislabeled cause as soon as you notice it, in the report and in the handoff. +- **Report what the sandbox lacks.** A missing tool (a package manager, a + validator, a container runtime) is a fact, not a reason to skip a check + silently. Say which checks did not run, and ask before installing anything. - **Host-run scripts.** When the user must run something on their machine, give one line starting with a `cd` to the right worktree, or a script plus a one-line invocation. Scripts must: From e729463891014374058c4b8b7b0d6a5ad0882252 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 08:43:44 +0000 Subject: [PATCH 07/12] docs(first-mate-skill): changelog covers all 1.3.0 changes --- .../files/home/.agents/skills/paseo-first-mate/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md index c61a532..327275a 100644 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md @@ -72,7 +72,7 @@ portability: generic_llm: false changelog: - version: "1.3.0" - changes: "One coordinator and one wait loop per effort: the wait script now holds an owner-keyed lock (PASEO_AGENT_ID) and has status, stop, and take-over; starting is idempotent and never stacks loops; a resumed or forked session is a non-coordinator until the user says otherwise; never report a loop as running without a status result from this turn; new troubleshooting rows." + changes: "One coordinator and one wait loop per effort (owner-keyed flock in the wait script with status, stop, and take-over; forks and resumed sessions are non-coordinators; never report a loop as running without a status result from this turn). Detector watches branches and workflows. Fresh-base rule, verified agent claims, and reading finish notices against agent status. Runtime acceptance signal, mutation proof, and post-merge proof. Repository PR standards override the template. Pending Decisions and Held Items handoff section, recorded permission policies, shape-only host reads, drafts for human threads, cleanup on merge, and new troubleshooting rows." - version: "1.2.0" changes: "Add a change-detecting check and background wait loop (bundled assets) as the token-cheap default, alongside the Paseo heartbeat; add the end-the-turn rule for quiet wake-ups, standing ready-when-green grant, agent self-review and reuse rules, filing rule, host-run script hygiene, reviewer reply guidance, and new troubleshooting rows." - version: "1.1.0" From 96222736b86c29612e23a3e57a7a60c4691c5b79 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 08:52:04 +0000 Subject: [PATCH 08/12] fix(first-mate-skill): stop works mid-detector; consent for take-over; clearer mechanisms Adversarial review findings: - first-mate-wait.sh ran the detector in a command substitution, so SIGTERM was deferred until its network calls finished and stop/take-over timed out. The detector now runs in the background and is waited on, and the cleanup trap kills it. The owner file is written then renamed so status and stop never read a partial file. A test with a slow stub detector fails on the previous script and passes now. - first-mate-check.py stored a bare repo name for unregistered repos, which later failed to resolve and aborted the whole run; it now keeps the full OWNER/repo. The state file is written atomically. - SKILL.md: take-over and stop --force need the user's explicit word each time (invariant 12), and the heartbeat preference is folded into the heartbeat mechanism instead of reading as a third one. --- .../.agents/skills/paseo-first-mate/SKILL.md | 8 +++++--- .../paseo-first-mate/assets/first-mate-check.py | 6 ++++-- .../paseo-first-mate/assets/first-mate-wait.sh | 17 +++++++++++++++-- first-mate-skill/scripts/test-wait-lock | 16 +++++++++++++++- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md index 327275a..85c1a1d 100644 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md @@ -153,6 +153,8 @@ Single-change and read-only work are covered by `avoid_when` above. 12. One coordinator per effort. A resumed or forked session is a non-coordinator until the user says it is the coordinator (see section 1). A non-coordinator starts no wait loop and takes no action on the watched PRs and agents. + `take-over` and `stop --force` act on another agent's loop and need the user's + explicit word each time; a standing grant does not cover them. ## Delivery Levels @@ -448,12 +450,12 @@ one the user prefers and record the choice and any later change in the handoff: on an interval and exits only when something changed. The harness notifies the coordinator when it finishes, so waiting costs no model calls. There is at most one loop per `FIRST_MATE_DIR`, and it is owned by the agent that started it. -- Prefer the heartbeat if the user plans to fork or resume sessions often. A - heartbeat is a named schedule, so it is one object that no fork can duplicate, - at the price of a model call per run. - **Paseo heartbeat.** A scheduled prompt that runs the check each time. Use `paseo heartbeat update --cron ...` to change its cadence (for example, hourly while nothing is moving, every 15 minutes once something changes). + Prefer it when the user plans to fork or resume sessions often: a heartbeat is a + named schedule, so it is one object that no fork can duplicate, at the price of a + model call per run. Either way, a quiet wake-up must not turn into filler. Empty calls (`true`, `date > /dev/null`) cost tokens, change nothing, and can run away into hundreds diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py index 66ddd48..4010667 100755 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py @@ -119,7 +119,7 @@ def short(w, repo): for a, r in w["repos"].items(): if r == repo: return a - return repo.split("/")[-1] + return repo def run(cmd): @@ -241,8 +241,10 @@ def load_state(): def save_state(s): - with open(STATE, "w") as fh: + tmp = STATE + ".tmp" + with open(tmp, "w") as fh: json.dump(s, fh, indent=1, sort_keys=True) + os.replace(tmp, STATE) def manage(w, argv): diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh index 86c9c1d..f7106aa 100755 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh @@ -73,8 +73,12 @@ cmd_stop() { } SLEEP_PID= +DET_PID= +OUTF= cleanup() { [ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null + [ -n "$DET_PID" ] && kill "$DET_PID" 2>/dev/null + [ -n "$OUTF" ] && rm -f -- "$OUTF" [ "$(info_get pid)" = "$$" ] && rm -f -- "$INFO" } @@ -92,14 +96,23 @@ cmd_start() { "Use 'take-over' only if the user agreed that you are the coordinator." >&2 exit 5 fi + # Write then rename, so status and stop never read a half-written owner file. printf 'owner=%s\npid=%s\nstarted=%s\ninterval=%s\nmax=%s\n' \ - "$ME" "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$interval" "$max" > "$INFO" + "$ME" "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$interval" "$max" > "$INFO.new" \ + && mv -f -- "$INFO.new" "$INFO" + OUTF=$(mktemp "$DIR/.wait.out.XXXXXX") || exit 2 trap cleanup EXIT trap 'exit 143' TERM INT deadline=$(( $(date +%s) + max * 60 )) while :; do # Children must not inherit the lock fd, or a killed loop would keep the lock. - out=$(python3 "$HERE/first-mate-check.py" 2>&1 9>&-) + # The detector runs in the background and is waited on, so a stop (SIGTERM) is + # handled at once instead of after the detector's network calls finish. + python3 "$HERE/first-mate-check.py" >"$OUTF" 2>&1 9>&- & + DET_PID=$! + wait "$DET_PID" + DET_PID= + out=$(cat -- "$OUTF") if [ -n "$out" ]; then printf '%s\n' "$out" exit 0 diff --git a/first-mate-skill/scripts/test-wait-lock b/first-mate-skill/scripts/test-wait-lock index 16b4ff1..04aaeba 100755 --- a/first-mate-skill/scripts/test-wait-lock +++ b/first-mate-skill/scripts/test-wait-lock @@ -21,7 +21,9 @@ cp "$WAIT_SRC" "$WORK/bin/first-mate-wait.sh" # Stub detector: quiet until a "change" file appears, then prints once. cat > "$WORK/bin/first-mate-check.py" <<'PY' #!/usr/bin/env python3 -import os +import os, time +if os.path.exists(os.path.join(os.environ["FIRST_MATE_DIR"], "slow")): + time.sleep(30) p = os.path.join(os.environ["FIRST_MATE_DIR"], "change") if os.path.exists(p): print("CHANGED stub") @@ -89,6 +91,18 @@ check "exactly one loop after take-over" "1" "$(pgrep -fc "$WORK/bin/first-mate- as bob stop >/dev/null 2>&1; wait 2>/dev/null +echo "stop works while the detector is mid-run" +touch "$FIRST_MATE_DIR/slow" +as alice 1 5 >/dev/null 2>&1 & +for _ in $(seq 1 50); do [ "$(as alice status)" != "none" ] && break; sleep 0.1; done +sleep 0.5 +start=$SECONDS +quick alice stop >/dev/null 2>&1; check "stop returns 0 during a slow detector" "0" "$?" +check "stop took under 4s" "yes" "$([ $((SECONDS - start)) -lt 4 ] && echo yes || echo no)" +check "status is none after slow stop" "none" "$(as alice status)" +wait 2>/dev/null +rm -f "$FIRST_MATE_DIR/slow" + echo "a change ends the loop with exit 0 and the report" touch "$FIRST_MATE_DIR/change" out="$(as alice 1 5 2>&1)"; rc=$? From c61123c32bb17a4452bca85ff4f5810aadf55897 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 18:58:49 +0000 Subject: [PATCH 09/12] fix(first-mate-skill): unused loop variable flagged by shellcheck (SC2034) --- .../.agents/skills/paseo-first-mate/assets/first-mate-wait.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh index f7106aa..f6b4b1b 100755 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh @@ -64,7 +64,7 @@ cmd_stop() { return 2 fi kill "$pid" - for i in $(seq 1 50); do + for _ in $(seq 1 50); do if ! is_running; then echo "stopped (was owned by ${owner:-unknown})"; return 0; fi sleep 0.1 done From 37b964ee0c2547d3a2b6c9b88c01316018a0eb60 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 19:18:50 +0000 Subject: [PATCH 10/12] fix(first-mate-skill): make the wait script and its test work on macOS stop verified the loop's pid through /proc, which macOS lacks, so stop failed and the test's bare wait blocked forever on the loop it could not stop. The script now uses ps -p -o args=. The test uses gtimeout when timeout is missing, avoids pgrep -c (not on macOS), and has a watchdog (default 120s, FIRST_MATE_TEST_WATCHDOG) so a regression fails instead of hanging. Also drops an unused local flagged by shellcheck (SC2034). --- .../assets/first-mate-wait.sh | 8 +++++--- first-mate-skill/scripts/test-wait-lock | 19 ++++++++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh index f6b4b1b..ea234a7 100755 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh @@ -20,7 +20,8 @@ # # Ownership is the PASEO_AGENT_ID of the caller (override with FIRST_MATE_OWNER). The # lock is an flock(2) on FIRST_MATE_DIR/.wait.lock, so it frees itself when the loop -# dies, including by SIGKILL; there is no pidfile to go stale. Needs flock (util-linux). +# dies, including by SIGKILL; there is no pidfile to go stale. Needs flock: util-linux on +# Linux, or `brew install flock` on macOS. set -uo pipefail HERE=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) @@ -51,7 +52,7 @@ describe() { } cmd_stop() { - local force=0 owner pid i + local force=0 owner pid [ "${1:-}" = "--force" ] && force=1 if ! is_running; then echo "none running"; return 0; fi owner=$(info_get owner); pid=$(info_get pid) @@ -59,7 +60,8 @@ cmd_stop() { echo "refusing: the loop is owned by ${owner:-unknown}; use 'stop --force' or 'take-over'" >&2 return 5 fi - if [ -z "$pid" ] || ! tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q 'first-mate-wait.sh'; then + # ps works on Linux and macOS; /proc exists only on Linux. + if [ -z "$pid" ] || ! ps -p "$pid" -o args= 2>/dev/null | grep -q 'first-mate-wait.sh'; then echo "cannot verify that pid '${pid:-none}' is the loop; not killing anything" >&2 return 2 fi diff --git a/first-mate-skill/scripts/test-wait-lock b/first-mate-skill/scripts/test-wait-lock index 04aaeba..3f87b48 100755 --- a/first-mate-skill/scripts/test-wait-lock +++ b/first-mate-skill/scripts/test-wait-lock @@ -14,6 +14,12 @@ if ! command -v flock >/dev/null 2>&1; then echo "SKIP flock not installed" exit 0 fi +# GNU timeout is `timeout` on Linux and `gtimeout` (brew install coreutils) on macOS. +TIMEOUT_BIN=$(command -v timeout || command -v gtimeout || true) +if [ -z "$TIMEOUT_BIN" ]; then + echo "SKIP timeout/gtimeout not installed" + exit 0 +fi WORK="$(mktemp -d "${TMPDIR:-/tmp}/first-mate-wait-test.XXXXXX")" || exit 1 mkdir -p "$WORK/bin" "$WORK/state" @@ -43,8 +49,15 @@ check() { # name expected actual } as() { local who=$1; shift; FIRST_MATE_OWNER=$who "$WAIT" "$@"; } # For calls that must return at once; a mutated script would block here. -quick() { local who=$1; shift; FIRST_MATE_OWNER=$who timeout --foreground 5 "$WAIT" "$@"; } +quick() { local who=$1; shift; FIRST_MATE_OWNER=$who "$TIMEOUT_BIN" --foreground 5 "$WAIT" "$@"; } +# Watchdog: a test must never hang a terminal, whatever the platform does. +WATCHDOG_SECS=${FIRST_MATE_TEST_WATCHDOG:-120} +( sleep "$WATCHDOG_SECS"; echo "FAIL watchdog: test exceeded ${WATCHDOG_SECS}s" >&2; kill -TERM $$ ) & +WATCHDOG=$! +disown "$WATCHDOG" 2>/dev/null # so the bare `wait` calls below do not wait for it cleanup() { + pkill -P "$WATCHDOG" 2>/dev/null + kill "$WATCHDOG" 2>/dev/null FIRST_MATE_OWNER=any "$WAIT" stop --force >/dev/null 2>&1 # Only processes started from this test's own directory. pkill -f "$WORK/bin/" 2>/dev/null @@ -64,7 +77,7 @@ check "bob sees other" "other" "$(as bob status | cut -d' ' -f1)" echo "a second start never stacks" quick alice 1 5 >/dev/null 2>&1; check "alice restart exits 4" "4" "$?" quick bob 1 5 >/dev/null 2>&1; check "bob start exits 5" "5" "$?" -check "still exactly one loop" "1" "$(pgrep -fc "$WORK/bin/first-mate-wait.sh (take-over|[0-9])")" +check "still exactly one loop" "1" "$(pgrep -f "$WORK/bin/first-mate-wait.sh (take-over|[0-9])" | wc -l | tr -d " ")" echo "stop respects ownership" as bob stop >/dev/null 2>&1; check "bob cannot stop alice" "5" "$?" @@ -87,7 +100,7 @@ for _ in $(seq 1 50); do [ "$(as alice status)" != "none" ] && break; sleep 0.1; as bob take-over 1 5 >"$WORK/bob.out" 2>&1 & for _ in $(seq 1 80); do [ "$(as bob status | cut -d' ' -f1)" = "mine" ] && break; sleep 0.1; done check "bob now owns it" "mine" "$(as bob status | cut -d' ' -f1)" -check "exactly one loop after take-over" "1" "$(pgrep -fc "$WORK/bin/first-mate-wait.sh (take-over|[0-9])")" +check "exactly one loop after take-over" "1" "$(pgrep -f "$WORK/bin/first-mate-wait.sh (take-over|[0-9])" | wc -l | tr -d " ")" as bob stop >/dev/null 2>&1; wait 2>/dev/null From 25970f1a8960d7cd67e9e2be25ae274c9525812b Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 20:14:29 +0000 Subject: [PATCH 11/12] fix(first-mate-skill): do not act on a loop that holds the lock but has not published its owner CI failed in the take-over test. The loop takes the lock first and writes its owner file after, so on a slow runner a start, status, or stop in that gap saw a held lock with no owner (or a stale file from a SIGKILLed loop) and treated the loop as someone else's or as unverifiable. Callers now wait up to about 5s for an owner file naming a live first-mate-wait.sh process before deciding, and fall back to none if the loop dies meanwhile. The test gains a slow-starting-loop case (lock held, owner file delayed, stale file present). It fails on the previous script and passes now, including with the owner write delayed 1s everywhere and under CPU load. --- .../assets/first-mate-wait.sh | 26 +++++++++++++++++++ first-mate-skill/scripts/test-wait-lock | 22 +++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh index ea234a7..e0f0bcf 100755 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh @@ -42,9 +42,32 @@ info_get() { sed -n "s/^$1=//p" "$INFO" 2>/dev/null | head -n 1; } # True when some process holds the lock. The subshell takes and releases it. is_running() { if ( exec 8>"$LOCK"; flock -n 8 ); then return 1; else return 0; fi; } +# True when the owner file names a live first-mate-wait.sh process. A loop killed with +# SIGKILL leaves its owner file behind, so the file alone is not proof. +info_valid() { + local p + p=$(info_get pid) + [ -n "$p" ] && ps -p "$p" -o args= 2>/dev/null | grep -q 'first-mate-wait.sh' +} + +# The loop takes the lock before it writes the owner file. When the lock is held, +# wait (up to about 5s) for a valid owner file so a caller never acts on a half-started +# loop or on a stale file. Returns 1 if the loop died meanwhile, or never published one. +await_info() { + local n + for n in $(seq 1 50); do + info_valid && return 0 + is_running || return 1 + sleep 0.1 + done + return 1 +} + describe() { if ! is_running; then echo "none"; return 0; fi local owner kind + await_info + is_running || { echo "none"; return 0; } owner=$(info_get owner) if [ "$owner" = "$ME" ]; then kind=mine; else kind=other; fi echo "$kind owner=${owner:-unknown} pid=$(info_get pid) started=$(info_get started)" \ @@ -55,6 +78,8 @@ cmd_stop() { local force=0 owner pid [ "${1:-}" = "--force" ] && force=1 if ! is_running; then echo "none running"; return 0; fi + await_info + is_running || { echo "none running"; return 0; } owner=$(info_get owner); pid=$(info_get pid) if [ "$owner" != "$ME" ] && [ "$force" -eq 0 ]; then echo "refusing: the loop is owned by ${owner:-unknown}; use 'stop --force' or 'take-over'" >&2 @@ -89,6 +114,7 @@ cmd_start() { case "$interval$max" in *[!0-9]*|'') usage; exit 2 ;; esac exec 9>"$LOCK" if ! flock -n 9; then + await_info owner=$(info_get owner) if [ "$owner" = "$ME" ]; then echo "a loop is already running and you own it ($(describe)); not starting another" diff --git a/first-mate-skill/scripts/test-wait-lock b/first-mate-skill/scripts/test-wait-lock index 3f87b48..d58d3eb 100755 --- a/first-mate-skill/scripts/test-wait-lock +++ b/first-mate-skill/scripts/test-wait-lock @@ -94,6 +94,24 @@ kill -9 "$pid"; wait 2>/dev/null sleep 0.3 check "lock is free after kill -9" "none" "$(as bob status)" +echo "a slow-starting loop (lock held, owner file not yet written, stale file present)" +# $INFO from the kill -9 case above is still on disk with a dead pid. +# A copy that keeps the real script name (the loop's pid is validated by name). +mkdir -p "$WORK/slow" +cp "$WORK/bin/first-mate-check.py" "$WORK/slow/" +sed 's|^ # Write then rename, so status| sleep 1.5\n # Write then rename, so status|' "$WAIT" > "$WORK/slow/first-mate-wait.sh" +chmod +x "$WORK/slow/first-mate-wait.sh" +FIRST_MATE_OWNER=alice "$WORK/slow/first-mate-wait.sh" 1 5 >/dev/null 2>&1 & +for _ in $(seq 1 50); do + ( exec 8>"$FIRST_MATE_DIR/.wait.lock"; flock -n 8 ) || break; sleep 0.1 +done +check "alice sees mine while still starting" "mine" "$(as alice status | cut -d' ' -f1)" +quick bob 1 5 >/dev/null 2>&1; check "bob is refused (exit 5), not treated as unowned" "5" "$?" +quick alice stop >/dev/null 2>&1; check "alice stops it after a slow start" "0" "$?" +wait 2>/dev/null +check "none after the slow loop is stopped" "none" "$(as alice status)" +rm -rf "$WORK/slow" + echo "take-over replaces another owner" as alice 1 5 >/dev/null 2>&1 & for _ in $(seq 1 50); do [ "$(as alice status)" != "none" ] && break; sleep 0.1; done @@ -102,7 +120,9 @@ for _ in $(seq 1 80); do [ "$(as bob status | cut -d' ' -f1)" = "mine" ] && brea check "bob now owns it" "mine" "$(as bob status | cut -d' ' -f1)" check "exactly one loop after take-over" "1" "$(pgrep -f "$WORK/bin/first-mate-wait.sh (take-over|[0-9])" | wc -l | tr -d " ")" -as bob stop >/dev/null 2>&1; wait 2>/dev/null +# --force and `any`: end whoever holds it, so the bare wait below can never block on a +# loop an earlier failed step left running. +FIRST_MATE_OWNER=any "$WAIT" stop --force >/dev/null 2>&1; wait 2>/dev/null echo "stop works while the detector is mid-run" touch "$FIRST_MATE_DIR/slow" From 7a8492ce420d3c941a60a4c5fdee4879d311bd96 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 20:32:16 +0000 Subject: [PATCH 12/12] fix(first-mate-skill): shellcheck SC2034; a start must not lose to a status probe shellcheck (host and CI) flagged an unused loop variable in await_info; it is now `_`. The local shellcheck install also made the gap visible: scripts/verify failed intermittently in the locking test. Cause: status and stop probe the lock by briefly taking it, so a start that runs at the same instant failed flock -n and was refused as if another owner held a loop. cmd_start now retries until the owner file names a live loop, the lock is free, or about 5s pass. A test holds the lock for 1s with no owner file and starts in the middle; it fails on the previous script and passes now. --- .../paseo-first-mate/assets/first-mate-wait.sh | 18 +++++++++++++----- first-mate-skill/scripts/test-wait-lock | 12 ++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh index e0f0bcf..59876a7 100755 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh @@ -54,8 +54,8 @@ info_valid() { # wait (up to about 5s) for a valid owner file so a caller never acts on a half-started # loop or on a stale file. Returns 1 if the loop died meanwhile, or never published one. await_info() { - local n - for n in $(seq 1 50); do + local _ + for _ in $(seq 1 50); do info_valid && return 0 is_running || return 1 sleep 0.1 @@ -110,11 +110,19 @@ cleanup() { } cmd_start() { - local interval=${1:-15} max=${2:-60} owner out deadline + local interval=${1:-15} max=${2:-60} owner out deadline got _ case "$interval$max" in *[!0-9]*|'') usage; exit 2 ;; esac exec 9>"$LOCK" - if ! flock -n 9; then - await_info + # status and stop probe the lock by briefly taking it, so a failed attempt is not + # proof that a loop is running. Retry until the owner file names a live loop (then + # it is real), the lock is free (we win it), or about 5s pass. + got=0 + for _ in $(seq 1 50); do + if flock -n 9; then got=1; break; fi + info_valid && break + sleep 0.1 + done + if [ "$got" -eq 0 ]; then owner=$(info_get owner) if [ "$owner" = "$ME" ]; then echo "a loop is already running and you own it ($(describe)); not starting another" diff --git a/first-mate-skill/scripts/test-wait-lock b/first-mate-skill/scripts/test-wait-lock index d58d3eb..d9f8388 100755 --- a/first-mate-skill/scripts/test-wait-lock +++ b/first-mate-skill/scripts/test-wait-lock @@ -112,6 +112,18 @@ wait 2>/dev/null check "none after the slow loop is stopped" "none" "$(as alice status)" rm -rf "$WORK/slow" +echo "a start that meets a brief lock probe still starts" +# status and stop probe the lock by briefly taking it. Hold it for 1s with no owner +# file, as a probe would, and start in the middle: it must wait and win, not refuse. +rm -f "$FIRST_MATE_DIR/.wait.owner" +( exec 7>"$FIRST_MATE_DIR/.wait.lock"; flock 7; sleep 1 ) & +sleep 0.2 +as alice 1 5 >/dev/null 2>&1 & +for _ in $(seq 1 80); do [ "$(as alice status | cut -d' ' -f1)" = "mine" ] && break; sleep 0.1; done +check "start waited out the probe and owns the loop" "mine" "$(as alice status | cut -d' ' -f1)" +quick alice stop >/dev/null 2>&1 +wait 2>/dev/null + echo "take-over replaces another owner" as alice 1 5 >/dev/null 2>&1 & for _ in $(seq 1 50); do [ "$(as alice status)" != "none" ] && break; sleep 0.1; done