From 1431fb9ca9b7cb67e96e21358601207914f37f28 Mon Sep 17 00:00:00 2001 From: hey137 Date: Thu, 16 Jul 2026 22:56:18 +0800 Subject: [PATCH 01/13] =?UTF-8?q?fix:=20=E5=AE=89=E5=85=A8=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=20Pi=20resume=20=E4=BC=9A=E8=AF=9D=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pi/extensions/fm-primary-pi-watch.ts | 74 +++--- .pi/extensions/fm-primary-turnend-guard.ts | 50 ++-- bin/fm-lock.sh | 233 +++++++++++++++--- docs/scripts.md | 2 +- docs/supervision-protocols/pi.md | 20 +- docs/turnend-guard.md | 20 ++ tests/fm-pi-primary-live-e2e.test.sh | 22 +- tests/fm-pi-watch-extension.test.sh | 257 +++++++++++++++++++- tests/fm-session-lock.test.sh | 261 +++++++++++++++++++++ tests/fm-turnend-guard.test.sh | 4 + 10 files changed, 824 insertions(+), 119 deletions(-) create mode 100755 tests/fm-session-lock.test.sh diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 644f939ce..5e0877626 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -12,7 +12,7 @@ type ArmResult = { message: string; }; -type LockOwnership = "owned" | "missing" | "other"; +type LockOwnership = "owned" | "missing" | "dead" | "other" | "malformed" | "unknown"; const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); @@ -22,50 +22,35 @@ const fmRoot = process.env.FM_ROOT_OVERRIDE || root; const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`; const armScript = `${fmRoot}/bin/fm-watch-arm.sh`; +const lockScript = `${fmRoot}/bin/fm-lock.sh`; const marker = `${state}/.pi-watch-extension-loaded`; const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; let child: any = null; let seq = 0; -function parentPid(pid: string): string { - const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" }); - if (result.status !== 0) return ""; - return result.stdout.trim(); -} - -function pidAlive(pid: string): boolean { - try { - process.kill(Number(pid), 0); - return true; - } catch { - return false; - } +function lockEnvironment() { + return { + ...process.env, + FM_HOME: fmHome, + FM_ROOT_OVERRIDE: fmRoot, + FM_STATE_OVERRIDE: state, + }; } function lockOwnership(): LockOwnership { - let lockPid = ""; - try { - lockPid = readFileSync(`${state}/.lock`, "utf8").trim(); - } catch { - return "missing"; + const result = spawnSync(lockScript, ["ownership"], { encoding: "utf8", env: lockEnvironment() }); + const ownership = String(result.stdout || "").trim(); + if (result.status !== 0) return "unknown"; + if (["owned", "missing", "dead", "other", "malformed", "unknown"].includes(ownership)) { + return ownership as LockOwnership; } - if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other"; - let pid = String(process.pid); - for (let i = 0; i < 8; i += 1) { - if (pid === lockPid) return "owned"; - pid = parentPid(pid); - if (!pid || pid === "1") break; - } - return pidAlive(lockPid) ? "other" : "missing"; -} - -function sessionOwnsLock(): boolean { - return lockOwnership() === "owned"; + return "unknown"; } function markLoaded(): void { - if (lockOwnership() === "other") return; + const ownership = lockOwnership(); + if (ownership === "other" || ownership === "malformed" || ownership === "unknown") return; mkdirSync(state, { recursive: true }); writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); } @@ -104,7 +89,25 @@ export default function (pi: ExtensionAPI) { } function startArm(): ArmResult { - if (!sessionOwnsLock()) return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; + let ownership = lockOwnership(); + let acquisitionError = ""; + let attemptedAcquisition = false; + if (ownership === "missing" || ownership === "dead") { + attemptedAcquisition = true; + const acquisition = spawnSync(lockScript, [], { encoding: "utf8", env: lockEnvironment() }); + acquisitionError = String(acquisition.stderr || "").trim().split(/\r?\n/)[0] || ""; + ownership = lockOwnership(); + } + if (ownership === "other") { + const message = attemptedAcquisition + ? "watcher: lock ownership changed during resume - a verified live firstmate session now owns this home" + : "watcher: read-only - a verified live firstmate session owns this home"; + return { ok: false, message }; + } + if (ownership !== "owned") { + const detail = acquisitionError ? ` (${acquisitionError})` : ""; + return { ok: false, message: `watcher: lock acquisition failed - session lock is ${ownership}${detail}` }; + } markLoaded(); if (child) return { ok: true, message: "watcher: healthy - Pi extension already has an arm child" }; const id = ++seq; @@ -150,8 +153,9 @@ export default function (pi: ExtensionAPI) { return { ok: true, message: `watcher: started Pi extension arm child ${id}` }; } - pi.on?.("session_start", () => { - markLoaded(); + pi.on?.("session_start", (_event, ctx) => { + const result = startArm(); + if (!result.ok) ctx?.ui?.notify?.(result.message, "warning"); }); pi.on?.("session_shutdown", () => { stopArm(); diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index fe90145a1..2621701d0 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -7,57 +7,47 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; let guardFollowupActive = false; -type LockOwnership = "owned" | "missing" | "other"; +type LockOwnership = "owned" | "missing" | "dead" | "other" | "malformed" | "unknown"; const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); const root = resolve(extensionDir, "../.."); const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; +const fmRoot = process.env.FM_ROOT_OVERRIDE || root; const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; +const lockScript = `${fmRoot}/bin/fm-lock.sh`; const marker = `${state}/.pi-turnend-extension-loaded`; const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; -function parentPid(pid: string): string { - const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" }); - if (result.status !== 0) return ""; - return result.stdout.trim(); -} - -function pidAlive(pid: string): boolean { - try { - process.kill(Number(pid), 0); - return true; - } catch { - return false; - } +function lockEnvironment() { + return { + ...process.env, + FM_HOME: fmHome, + FM_ROOT_OVERRIDE: fmRoot, + FM_STATE_OVERRIDE: state, + }; } function lockOwnership(): LockOwnership { - let lockPid = ""; - try { - lockPid = readFileSync(`${state}/.lock`, "utf8").trim(); - } catch { - return "missing"; - } - if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other"; - let pid = String(process.pid); - for (let i = 0; i < 8; i += 1) { - if (pid === lockPid) return "owned"; - pid = parentPid(pid); - if (!pid || pid === "1") break; + const result = spawnSync(lockScript, ["ownership"], { encoding: "utf8", env: lockEnvironment() }); + const ownership = String(result.stdout || "").trim(); + if (result.status !== 0) return "unknown"; + if (["owned", "missing", "dead", "other", "malformed", "unknown"].includes(ownership)) { + return ownership as LockOwnership; } - return pidAlive(lockPid) ? "other" : "missing"; + return "unknown"; } function markLoaded(): void { - if (lockOwnership() === "other") return; + const ownership = lockOwnership(); + if (ownership === "other" || ownership === "malformed" || ownership === "unknown") return; mkdirSync(state, { recursive: true }); writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); } function runGuard(): Promise<{ code: number; stderr: string }> { return new Promise((resolveResult) => { - const child = spawn(`${root}/bin/fm-turnend-guard.sh`, { + const child = spawn(`${fmRoot}/bin/fm-turnend-guard.sh`, { stdio: ["pipe", "ignore", "pipe"], }); let stderr = ""; @@ -79,7 +69,7 @@ function runGuard(): Promise<{ code: number; stderr: string }> { // script owns its own decision and is inert outside the real primary checkout. function runChecker(script: string, command: string): Promise<{ code: number; stderr: string }> { return new Promise((resolveResult) => { - const child = spawn(`${root}/bin/${script}`, ["--command", command], { + const child = spawn(`${fmRoot}/bin/${script}`, ["--command", command], { stdio: ["ignore", "ignore", "pipe"], }); let stderr = ""; diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 33e4b0d27..1b6f93d8e 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash -# Acquire or inspect the per-home firstmate session lock. -# Writes the harness (agent) process PID found by walking the shell's ancestry, -# which lives as long as the firstmate session - unlike the transient subshell -# PID of any one tool call, which is dead moments after it is written. -# Usage: fm-lock.sh acquire; exit 1 if another live session holds it -# fm-lock.sh status print holder and liveness; always exits 0 +# Own the per-home firstmate session lock. +# The lock file contains exactly one verified harness PID and is changed only by +# this script inside a portable cross-process acquisition mutex. +# +# Usage: fm-lock.sh acquire or idempotently confirm this session +# fm-lock.sh status print holder and liveness; always exits 0 +# fm-lock.sh ownership print owned, missing, dead, other, malformed, or unknown set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -12,50 +13,208 @@ 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}" LOCK="$STATE/.lock" -mkdir -p "$STATE" +ACQUIRE_LOCK="$STATE/.session-lock.acquire" +LOCK_PID= +LOCK_STATE= +LOCK_PENDING= -# Known harness command names; extend when a new adapter is verified. -HARNESS_RE='claude|codex|opencode|grok|^pi$' +harness_name_is_exact() { + case "$1" in + claude|codex|opencode|pi|grok) return 0 ;; + *) return 1 ;; + esac +} + +interpreter_script_basename() { + local args=$1 script + script=$(printf '%s\n' "$args" | awk ' + { + for (i = 2; i <= NF; i += 1) { + if ($i == "--") { + if (i + 1 <= NF) print $(i + 1) + exit + } + if (substr($i, 1, 1) == "-") continue + print $i + exit + } + } + ') + [ -n "$script" ] || return 1 + script=${script#\"} + script=${script%\"} + script=${script#\'} + script=${script%\'} + printf '%s\n' "${script##*/}" +} + +process_is_harness() { + local pid=$1 comm base args script_base + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 + comm=${comm#"${comm%%[![:space:]]*}"} + comm=${comm%"${comm##*[![:space:]]}"} + base=${comm##*/} + if harness_name_is_exact "$base"; then + return 0 + fi + case "$base" in + node|nodejs|python|python3) + args=$(ps -o args= -p "$pid" 2>/dev/null) || return 1 + script_base=$(interpreter_script_basename "$args") || return 1 + harness_name_is_exact "$script_base" + ;; + *) return 1 ;; + esac +} harness_pid() { - local pid=$$ comm args + local pid=$$ parent for _ in 1 2 3 4 5 6 7 8; do - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - args=$(ps -o args= -p "$pid" 2>/dev/null) - if printf '%s' "$(basename "$comm")" | grep -qE "$HARNESS_RE"; then - echo "$pid"; return 0 + if process_is_harness "$pid"; then + printf '%s\n' "$pid" + return 0 fi - # Bare interpreter (e.g. node): match the harness name in its script path. - case "$comm" in - *node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { echo "$pid"; return 0; } ;; + parent=$(ps -o ppid= -p "$pid" 2>/dev/null) || return 1 + parent=${parent//[[:space:]]/} + case "$parent" in + ''|*[!0-9]*|0|1) return 1 ;; esac - pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 + pid=$parent done return 1 } -holder_alive() { # true if $1 is a live process that looks like a harness - local pid=$1 comm +holder_alive() { + local pid=$1 + case "$pid" in + ''|*[!0-9]*|0|1) return 1 ;; + esac kill -0 "$pid" 2>/dev/null || return 1 - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - printf '%s' "$(basename "$comm") $(ps -o args= -p "$pid" 2>/dev/null)" | grep -qE "$HARNESS_RE" + process_is_harness "$pid" } -if [ "${1:-}" = "status" ]; then - if [ ! -f "$LOCK" ]; then echo "lock: free"; exit 0; fi - old=$(cat "$LOCK") - if holder_alive "$old"; then echo "lock: held by live harness pid $old"; else echo "lock: stale (pid $old dead or not a harness)"; fi - exit 0 -fi +read_lock_pid() { + local pid + LOCK_PID= + if [ -L "$LOCK" ]; then + return 2 + fi + if [ ! -e "$LOCK" ]; then + return 1 + fi + [ -f "$LOCK" ] || return 2 + pid=$(awk 'NR == 1 && /^[0-9]+$/ { value = $0; next } { exit 1 } END { if (NR != 1 || value == "") exit 1; print value }' "$LOCK" 2>/dev/null) || return 2 + case "$pid" in + 0|1) return 2 ;; + esac + [ "$pid" -gt 1 ] 2>/dev/null || return 2 + LOCK_PID=$pid + return 0 +} -me=$(harness_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; } -if [ -f "$LOCK" ]; then - old=$(cat "$LOCK") - if [ "$old" != "$me" ] && holder_alive "$old"; then - echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 - exit 1 +classify_lock() { + local me=${1:-} read_status + LOCK_STATE= + read_status=0 + read_lock_pid || read_status=$? + case "$read_status" in + 1) LOCK_STATE=missing; return ;; + 2) LOCK_STATE=malformed; return ;; + esac + if [ -n "$me" ] && [ "$LOCK_PID" = "$me" ]; then + LOCK_STATE=owned + elif holder_alive "$LOCK_PID"; then + LOCK_STATE=other + else + LOCK_STATE=dead fi +} + +lock_ownership() { + classify_lock "${1:-}" + printf '%s\n' "$LOCK_STATE" +} + +write_lock_atomically() { + local pid=$1 temporary + temporary=$(mktemp "$STATE/.lock.pending.XXXXXX") || return 1 + LOCK_PENDING=$temporary + if ! printf '%s\n' "$pid" > "$temporary"; then + rm -f "$temporary" + LOCK_PENDING= + return 1 + fi + if ! mv "$temporary" "$LOCK"; then + rm -f "$temporary" + LOCK_PENDING= + return 1 + fi + LOCK_PENDING= +} + +case "${1:-}" in + status) + classify_lock + case "$LOCK_STATE" in + missing) printf 'lock: free\n' ;; + malformed) printf 'lock: malformed\n' ;; + other) printf 'lock: held by live harness pid %s\n' "$LOCK_PID" ;; + dead) printf 'lock: stale (pid %s dead or not a harness)\n' "$LOCK_PID" ;; + esac + exit 0 + ;; + ownership) + if me=$(harness_pid); then + lock_ownership "$me" + else + printf 'unknown\n' + fi + exit 0 + ;; + ''|acquire) ;; + *) printf 'error: unknown fm-lock.sh command: %s\n' "$1" >&2; exit 2 ;; +esac + +me=$(harness_pid) || { printf 'error: cannot locate harness process in ancestry\n' >&2; exit 1; } +mkdir -p "$STATE" || { printf 'error: cannot create session state directory: %s\n' "$STATE" >&2; exit 1; } + +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" +if ! fm_lock_try_acquire "$ACQUIRE_LOCK"; then + printf 'error: session lock acquisition is already in progress; ownership was not changed\n' >&2 + exit 1 fi -echo "$me" > "$LOCK" -echo "lock acquired: harness pid $me" +release_acquire_lock() { + [ -n "$LOCK_PENDING" ] && rm -f "$LOCK_PENDING" + fm_lock_release "$ACQUIRE_LOCK" +} +trap release_acquire_lock EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +classify_lock "$me" +case "$LOCK_STATE" in + owned) + printf 'lock acquired: harness pid %s\n' "$me" + exit 0 + ;; + other) + printf 'error: another live firstmate session holds the lock (pid %s); operate read-only until resolved\n' "$LOCK_PID" >&2 + exit 1 + ;; + malformed) + printf 'error: session lock is malformed; ownership was not changed\n' >&2 + exit 1 + ;; + missing|dead) ;; + *) + printf 'error: unknown session lock state: %s\n' "$LOCK_STATE" >&2 + exit 1 + ;; +esac + +write_lock_atomically "$me" || { printf 'error: failed to publish session lock ownership\n' >&2; exit 1; } +classify_lock "$me" +[ "$LOCK_STATE" = owned ] || { printf 'error: session lock ownership changed during publication\n' >&2; exit 1; } +printf 'lock acquired: harness pid %s\n' "$me" diff --git a/docs/scripts.md b/docs/scripts.md index d0815745c..0db7010d6 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -73,7 +73,7 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-promote.sh` | Promote a scout task in place to a protected ship task | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | -| `fm-lock.sh` | Per-home firstmate session lock | +| `fm-lock.sh` | Classify, inspect, and atomically acquire the per-home firstmate session lock | | `fm-x-lib.sh` | Shared X-mode config, relay, and reply-threading helpers | | `fm-x-poll.sh` | One bounded X relay poll: stash pending mentions, print `x-mention ` | | `fm-x-reply.sh` | Post or dry-run preview a composed X-mode reply or follow-up | diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 19a188b60..27711d386 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -3,13 +3,16 @@ Mode: Pi extension background wake. When this session owns supervision and away mode is not active: 1. Drain first with `bin/fm-wake-drain.sh`. 2. Confirm the Pi primary auto-loaded both project extensions (plain `pi`, after approving project trust once per clone); if not, restart with `-e __FM_PI_TURNEND_EXT__ -e __FM_PI_EXT__` as a trust-free fallback. -3. Arm supervision with the `fm_watch_arm_pi` tool. +3. On `session_start`, the watcher extension reads ownership through `bin/fm-lock.sh ownership`. + A missing or dead lock is reacquired only through `bin/fm-lock.sh`, ownership is read again, and supervision starts only after the result is `owned`. + A verified live other owner remains unchanged and the extension refuses to arm. +4. After each watcher wake, re-arm supervision with the `fm_watch_arm_pi` tool. Use `/fm-watch-arm-pi` only as a human-entered fallback. Never run `bin/fm-watch-arm.sh` through Pi's bash tool because that foreground arm can wedge the agent and bypasses extension-owned cleanup. -4. The extension starts `bin/fm-watch-arm.sh --restart`, keeps the child attached to the live Pi process, and sends a follow-up user message when the child exits with an actionable watcher reason. -5. If the extension says the watcher is already healthy, do not start another cycle. -6. If the extension reports a watcher failure, drain queued wakes, inspect the failure text, and restart Pi with both extensions loaded if needed. -7. Never use shell `&` for watcher supervision. +5. The extension starts `bin/fm-watch-arm.sh --restart`, keeps the child attached to the live Pi process, and sends a follow-up user message when the child exits with an actionable watcher reason. +6. If the extension says the watcher is already healthy, do not start another cycle. +7. If the extension reports a watcher failure, drain queued wakes, inspect the failure text, and restart Pi with both extensions loaded if needed. +8. Never use shell `&` for watcher supervision. The arm mechanism above is extension-owned, not a model tool call, but a manual recovery probe that backgrounds, pipes, or bundles the arm is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, wired into the turn-end guard extension at `__FM_PI_TURNEND_EXT__`). The turn-end guard extension lives at `__FM_PI_TURNEND_EXT__`. @@ -26,3 +29,10 @@ Command run for the complete interactive regression: `FM_PI_LIVE_E2E=1 tests/fm- Observed output: `ok - Pi 0.80.5 live E2E rendered the tool, guarded once, woke, re-armed, and cleaned up on exit`. Command run for the installed-type contract: `tests/fm-pi-primary-types.test.sh`. Observed output: `ok - Pi primary extensions pass strict no-emit typecheck against Pi 0.80.5`. + +Verification on 2026-07-16 added subprocess-backed resume and lock-owner coverage without changing Pi core or global Pi installation state. +Command run: `bash tests/fm-session-lock.test.sh`. +Observed output included `ok - native Pi comm and argv are classified as a live holder`, `ok - a verified live other holder remains byte-for-byte unchanged`, and `ok - serialized concurrent contenders produce exactly one winner`. +Command run: `bash tests/fm-pi-watch-extension.test.sh`. +Observed output included `ok - Pi session_start reclaims missing/dead locks, refuses live other, and idempotently arms owned locks` and `ok - Pi watcher distinguishes verified competitor, acquisition failure, and changed ownership`. +The opt-in interactive fixture now expects native Pi ownership plus automatic first arm on `session_start`; it remains isolated behind `FM_PI_LIVE_E2E=1`. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 344417161..8251aac8d 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -43,6 +43,7 @@ All verified primary harnesses have a tracked integration: - `codex`: `.codex/hooks.json` registers a `Stop` hook that reads the hook payload once, anchors the executable to the hook command process working directory, verifies that root is firstmate-shaped and hook-bearing, and pipes the original payload to that checkout's `bin/fm-turnend-guard.sh`. - `opencode`: `.opencode/plugins/fm-primary-turnend-guard.js` listens for `session.idle`, lets the watcher-arm coordinator handle normal idle supervision first, runs the shared guard only when that coordinator does not act, and uses `client.session.promptAsync` to force one follow-up prompt when the guard returns 2. - `pi`: `.pi/extensions/fm-primary-turnend-guard.ts` listens for `agent_settled`, marks the extension version loaded for session-start checks, runs the shared guard once per logical agent run, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one follow-up prompt when the guard returns 2. + Both Pi extensions use `bin/fm-lock.sh ownership` as the single read-only classifier, while only the watcher extension may ask that production owner to acquire a missing or dead lock. - `grok`: `.grok/hooks/fm-primary-turnend-guard.json` registers a `Stop` hook that invokes `bin/fm-turnend-guard-grok.sh`. The adapter runs the shared guard and, when it returns 2, invokes `grok --resume -p ` with `GROK_TURNEND_GUARD_ACTIVE=1`. It does not pass `--permission-mode`, so the passive Stop hook cannot grant stronger tool permissions than Grok's resumed-session default. @@ -144,8 +145,27 @@ So the model was re-invoked solely by the background task's completion while idl This matches the harness tool contract that a `run_in_background` task "keeps running across turns and re-invokes you when it exits", and reproduces the 11s latency the task audit measured independently on the same harness version. No Herdr command was issued and no fleet state was touched; the experiment wrote only to the session scratchpad, which was discarded. +### 2026-07-16: native Pi resume lock recovery + +Pi 0.80.7 native resume restores the conversation without rerunning `bin/fm-session-start.sh`, so the watcher extension now checks the production lock owner during `session_start`. +The production owner matches native `pi` only by exact process basename and inspects argv only for the explicitly allowed bare interpreters `node`, `nodejs`, `python`, and `python3`. +Session-lock publication is serialized through the portable lock primitive in `bin/fm-wake-lib.sh`, while `bin/fm-lock.sh` remains the only writer of `state/.lock`. +The watcher calls acquisition only for `missing` or `dead`, reads ownership again, and starts supervision only after observing `owned`. +A verified live other holder and malformed lock remain byte-for-byte unchanged. + +Command run: `bash tests/fm-session-lock.test.sh`. +Observed output covered exact native names for Claude, Codex, OpenCode, Pi, and Grok, same-holder idempotence, dead reclaim, malformed refusal, missing ancestry, exact interpreter script matching, and one winner from two synchronized contenders. +Command run: `bash tests/fm-pi-watch-extension.test.sh`. +Observed output covered real tracked-extension `session_start` integration for missing, dead, verified live other, and owned locks, plus distinct competitor, acquisition-failure, and ownership-changed messages. +Command run: `bash tests/fm-turnend-guard.test.sh`. +Observed output kept the shared predicate and all five primary harness hook regressions green after removing the Pi guard's duplicate lock classifier. +The runtime-backend axis is not applicable because session-lock ownership is per `FM_HOME` and does not call tmux, Herdr, Zellij, Orca, or cmux adapters. +No Herdr lifecycle command was required for these subprocess fixtures. + ## Tests `tests/fm-turnend-guard.test.sh` covers the shared predicate, primary scoping (including a secondmate's own home being guarded like the main primary while its child worktrees stay exempt), `FM_HOME` and `FM_STATE_OVERRIDE` precedence, Pi logical-run latch behavior for no-tool and multi-tool runs, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and the Grok adapter's forced-resume loop guard and permission-mode regression. +`tests/fm-session-lock.test.sh` covers the shared session-lock classifier and serialized mutation contract for every supported primary harness. +`tests/fm-pi-watch-extension.test.sh` covers resume recovery through the production owner and the shared read-only ownership API used by both Pi extensions. The default behavior suite does not invoke live language-model harnesses. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the isolated interactive Pi regression recorded above. diff --git a/tests/fm-pi-primary-live-e2e.test.sh b/tests/fm-pi-primary-live-e2e.test.sh index a7b86c913..5a6dba1e6 100755 --- a/tests/fm-pi-primary-live-e2e.test.sh +++ b/tests/fm-pi-primary-live-e2e.test.sh @@ -55,6 +55,16 @@ wait_for_exact_line() { return 1 } +wait_for_file() { + local file=$1 attempts=${2:-120} i=0 + while [ "$i" -lt "$attempts" ]; do + [ -f "$file" ] && return 0 + sleep 0.5 + i=$((i + 1)) + done + return 1 +} + lab_pid_is_safe() { local pid=$1 command command=$(ps -p "$pid" -o command= 2>/dev/null || true) @@ -114,6 +124,10 @@ mkdir -p "$HOME_DIR/state" "$HOME_DIR/config" "$PI_DIR" wait_for_text "Trust project folder?" 40 || fail "Pi trust prompt did not appear" "$TMUX" -L "$SOCKET" send-keys -t "$SESSION" Enter wait_for_text "fm-primary-turnend-guard.ts" 60 || fail "Pi primary extensions did not load" +wait_for_file "$HOME_DIR/state/.watch.lock/pid" 60 || fail "Pi session_start did not automatically arm supervision" +session_lock_pid=$(cat "$HOME_DIR/state/.lock") +session_lock_comm=$(ps -p "$session_lock_pid" -o comm= 2>/dev/null || true) +[ "${session_lock_comm##*/}" = pi ] || fail "Pi session_start did not replace the shell fixture lock with the native Pi owner" send_prompt "Use the bash tool to run printf PI_E2E_BASH_ONE. Then reply exactly BASH-ONE." wait_for_exact_line "BASH-ONE" || fail "first bash turn did not complete" @@ -123,8 +137,8 @@ send_prompt "Use the bash tool to run printf PI_E2E_BASH_TWO. Then reply exactly wait_for_exact_line "BASH-TWO" || fail "second bash turn did not complete" : > "$HOME_DIR/state/pi-e2e.meta" -send_prompt "Reply exactly GUARD-TRIGGER with no tools. When the guard follow-up arrives, use fm_watch_arm_pi and never use bash to arm supervision. After any FIRSTMATE WATCHER WAKE, run bin/fm-wake-drain.sh, read the signaled status, call fm_watch_arm_pi to re-arm, and finish exactly REARMED." -wait_for_text "watcher: started Pi extension arm child 1" || fail "guard follow-up did not render the Pi watcher tool result" +send_prompt "Reply exactly READY with no tools. After any FIRSTMATE WATCHER WAKE, run bin/fm-wake-drain.sh, read the signaled status, call fm_watch_arm_pi to re-arm, and finish exactly REARMED." +wait_for_exact_line "READY" 120 || fail "Pi did not settle with the automatic watcher armed" printf 'done: pi live e2e watcher fire\n' > "$HOME_DIR/state/pi-e2e.status" wait_for_text "watcher: started Pi extension arm child 2" 180 || fail "watcher wake did not drain and re-arm through the Pi tool" @@ -132,7 +146,7 @@ wait_for_exact_line "REARMED" 120 || fail "Pi did not settle after re-arming wat pane=$(capture) guard_count=$(printf '%s\n' "$pane" | grep -Fc "TURN WOULD END BLIND - supervision is off." || true) -[ "$guard_count" -eq 1 ] || fail "expected one guard injection, saw $guard_count" +[ "$guard_count" -eq 0 ] || fail "automatic session_start arm should prevent guard injection, saw $guard_count" foreground_arm='$ bin/fm-watch-arm.sh' if printf '%s\n' "$pane" | grep -Fq "$foreground_arm"; then fail "Pi used a foreground bash watcher arm" @@ -151,4 +165,4 @@ wait_for_text "PI_EXIT=0" 60 || fail "Pi did not exit cleanly" wait_pid_dead "$watcher_pid" || fail "watcher child survived clean Pi exit" wait_pid_dead "$arm_pid" || fail "arm child survived clean Pi exit" -printf 'ok - Pi %s live E2E rendered the tool, guarded once, woke, re-armed, and cleaned up on exit\n' "$PI_VERSION" +printf 'ok - Pi %s live E2E reclaimed the native lock, auto-armed, woke, re-armed, and cleaned up on exit\n' "$PI_VERSION" diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 6fd36a3ff..a8f761868 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -14,7 +14,7 @@ export NODE_NO_WARNINGS=1 install_pi_watch_extension_fixture() { local repo=$1 - mkdir -p "$repo/.pi/extensions" "$repo/node_modules/typebox" + mkdir -p "$repo/.pi/extensions" "$repo/node_modules/typebox" "$repo/bin" cp "$EXT" "$repo/.pi/extensions/fm-primary-pi-watch.ts" cat > "$repo/node_modules/typebox/package.json" <<'JSON' {"name":"typebox","type":"module","exports":"./index.js"} @@ -26,6 +26,57 @@ export const Type = { }, }; JS + cat > "$repo/bin/fm-lock.sh" <<'SH' +#!/usr/bin/env bash +set -u +state=${FM_STATE_OVERRIDE:-${FM_HOME:?}/state} +lock="$state/.lock" +mkdir -p "$state" +classify() { + if [ "${FM_LOCK_FIXTURE_MODE:-}" = changed ] && [ -f "$state/.fixture-ownership-changed" ]; then + printf 'other\n' + return + fi + if [ "${FM_LOCK_FIXTURE_MODE:-}" = failure ] || [ "${FM_LOCK_FIXTURE_MODE:-}" = changed ]; then + printf 'missing\n' + return + fi + if [ "${FM_LOCK_FIXTURE_MODE:-}" = other ]; then + printf 'other\n' + return + fi + if [ ! -e "$lock" ]; then printf 'missing\n'; return; fi + value=$(cat "$lock" 2>/dev/null || true) + case "$value" in ''|*[!0-9]*|1) printf 'malformed\n'; return ;; esac + if [ "$value" = "$PPID" ]; then printf 'owned\n'; return; fi + if kill -0 "$value" 2>/dev/null; then printf 'other\n'; else printf 'dead\n'; fi +} +if [ "${1:-}" = "ownership" ]; then classify; exit 0; fi +if [ "${FM_LOCK_FIXTURE_MODE:-}" = failure ]; then + printf 'fixture acquisition failed\n' >&2 + exit 1 +fi +if [ "${FM_LOCK_FIXTURE_MODE:-}" = changed ]; then + : > "$state/.fixture-ownership-changed" + printf 'fixture acquisition lost\n' >&2 + exit 1 +fi +state_name=$(classify) +case "$state_name" in + owned) ;; + missing|dead) printf '%s\n' "$PPID" > "$lock" ;; + *) printf 'error: fixture lock refused: %s\n' "$state_name" >&2; exit 1 ;; +esac +printf 'lock acquired: harness pid %s\n' "$PPID" +SH + chmod +x "$repo/bin/fm-lock.sh" +} + +install_real_lock_owner() { + local repo=$1 + cp "$ROOT/bin/fm-lock.sh" "$repo/bin/fm-lock.sh" + cp "$ROOT/bin/fm-wake-lib.sh" "$repo/bin/fm-wake-lib.sh" + chmod +x "$repo/bin/fm-lock.sh" } test_tracked_extension_present_and_self_hashing() { @@ -41,12 +92,19 @@ test_tracked_extension_present_and_self_hashing() { assert_contains "$text" ".pi-watch-extension-loaded" "tracked extension missing loaded marker" assert_contains "$text" 'createHash("sha256").update(readFileSync(extensionFile)).digest("hex")' "tracked extension does not self-hash its own content for extensionVersion" assert_contains "$text" 'fileURLToPath(import.meta.url)' "tracked extension does not self-locate via import.meta.url" - assert_contains "$text" "sessionOwnsLock" "tracked extension missing session lock ownership check" - assert_contains "$text" 'type LockOwnership = "owned" | "missing" | "other"' "tracked extension does not distinguish missing lock from another owner" - assert_contains "$text" "readFileSync(\`\${state}/.lock\`" "tracked extension does not read the effective session lock" - assert_contains "$text" 'return pidAlive(lockPid) ? "other" : "missing"' "tracked extension does not allow a pre-lock load marker" - assert_contains "$text" 'if (lockOwnership() === "other") return' "tracked extension overwrites another live session marker" - assert_contains "$text" "if (!sessionOwnsLock()) return { ok: false" "tracked extension arms without the session lock" + assert_contains "$text" "const lockScript = \`\${fmRoot}/bin/fm-lock.sh\`" "tracked extension does not resolve the production lock owner" + assert_contains "$text" 'type LockOwnership = "owned" | "missing" | "dead" | "other" | "malformed" | "unknown"' "tracked extension does not preserve the lock owner states" + assert_contains "$text" 'spawnSync(lockScript, ["ownership"]' "tracked extension does not use the lock owner's read-only API" + assert_contains "$text" 'if (ownership === "missing" || ownership === "dead")' "tracked extension does not restrict acquisition to reclaimable states" + assert_contains "$text" 'const acquisition = spawnSync(lockScript, []' "tracked extension does not route acquisition through the production owner" + assert_contains "$text" 'if (ownership !== "owned")' "tracked extension arms without re-reading owned state" + assert_contains "$text" 'lock ownership changed during resume' "tracked extension does not distinguish a lost acquisition race" + assert_contains "$text" 'a verified live firstmate session owns this home' "tracked extension does not distinguish a verified competitor" + assert_contains "$text" 'lock acquisition failed' "tracked extension does not distinguish acquisition failure" + assert_contains "$text" 'const result = startArm();' "tracked extension does not recover and arm on session_start" + assert_not_contains "$text" 'function parentPid' "tracked extension kept a duplicate ancestry classifier" + assert_not_contains "$text" 'function pidAlive' "tracked extension kept a duplicate holder-liveness classifier" + assert_not_contains "$text" "readFileSync(\`\${state}/.lock\`" "tracked extension still classifies lock bytes itself" assert_contains "$text" "writeFileSync(marker, \`\${extensionVersion}\\n\${process.pid}\\n\`)" "tracked extension does not write the content version and process marker" assert_contains "$text" "const config = process.env.FM_CONFIG_OVERRIDE" "tracked extension missing effective config resolution" assert_contains "$text" "FM_CONFIG_OVERRIDE: config" "tracked extension does not pass the effective config to the watcher arm" @@ -293,6 +351,189 @@ EOF pass "Pi process-exit cleanup stops the attached arm child" } +test_pi_resume_session_recovers_only_reclaimable_locks() { + local scenario repo home plugin fakebin arm_log other_pid out status + for scenario in missing dead other owned; do + repo="$TMP_ROOT/pi-resume-$scenario-root" + home="$TMP_ROOT/pi-resume-$scenario-home" + fakebin="$TMP_ROOT/pi-resume-$scenario-fakebin" + arm_log="$TMP_ROOT/pi-resume-$scenario-arm.log" + mkdir -p "$home/state" "$home/config" "$fakebin" + install_pi_watch_extension_fixture "$repo" + install_real_lock_owner "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +printf 'arm\n' >> "${FM_ARM_LOG:?}" +trap 'exit 0' TERM INT +while :; do sleep 1; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +pid= +previous= +for argument in "$@"; do + if [ "$previous" = "-p" ]; then pid=$argument; break; fi + previous=$argument +done +case "$*" in + *"comm="*) + if [ "$pid" = "${FM_FAKE_PI_PID:-}" ] || [ "$pid" = "${FM_FAKE_OTHER_PID:-}" ]; then + printf '/opt/homebrew/bin/pi\n' + else + printf '/bin/bash\n' + fi + ;; + *"args="*) + if [ "$pid" = "${FM_FAKE_PI_PID:-}" ] || [ "$pid" = "${FM_FAKE_OTHER_PID:-}" ]; then + printf 'pi\n' + else + printf 'bash fm-lock.sh\n' + fi + ;; + *"ppid="*) + if [ "$pid" = "${FM_FAKE_PI_PID:-}" ] || [ "$pid" = "${FM_FAKE_OTHER_PID:-}" ]; then + printf '1\n' + else + printf '%s\n' "${FM_FAKE_PI_PID:-1}" + fi + ;; + *) exit 1 ;; +esac +SH + chmod +x "$fakebin/ps" + + other_pid= + if [ "$scenario" = other ]; then + sleep 300 & + other_pid=$! + fi + status=0 + out=$(CASE="$scenario" PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$arm_log" FM_FAKE_OTHER_PID="$other_pid" PATH="$fakebin:$PATH" node --input-type=module 2>&1 <<'EOF' +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +process.env.FM_FAKE_PI_PID = String(process.pid); +const lock = `${process.env.FM_HOME}/state/.lock`; +if (process.env.CASE === "dead") writeFileSync(lock, "999999\n"); +if (process.env.CASE === "other") writeFileSync(lock, `${process.env.FM_FAKE_OTHER_PID}\n`); +if (process.env.CASE === "owned") writeFileSync(lock, `${process.pid}\n`); +const before = existsSync(lock) ? readFileSync(lock) : null; + +const handlers = new Map(); +let tool = null; +const notifications = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async () => {}, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const sessionStart = handlers.get("session_start"); +if (!sessionStart) throw new Error("session_start handler was not registered"); +await sessionStart({ type: "session_start" }, { + ui: { + notify(message) { + notifications.push(message); + }, + }, +}); +for (let i = 0; i < 100; i += 1) { + const observable = process.env.CASE === "other" ? notifications.length > 0 : existsSync(process.env.FM_ARM_LOG); + if (observable) break; + await new Promise((resolve) => setTimeout(resolve, 20)); +} + +if (process.env.CASE === "other") { + const after = readFileSync(lock); + if (!before?.equals(after)) throw new Error("live-other lock bytes changed"); + if (existsSync(process.env.FM_ARM_LOG)) throw new Error("live-other session armed the watcher"); + if (!notifications.some((message) => message.includes("verified live"))) { + throw new Error(`live-other refusal was not explicit: ${notifications.join(" | ")}`); + } +} else { + const acquired = readFileSync(lock, "utf8").trim(); + if (acquired !== String(process.pid)) throw new Error(`resume lock owner is ${acquired}, expected ${process.pid}`); + if (!existsSync(process.env.FM_ARM_LOG)) throw new Error(`${process.env.CASE} resume did not arm the watcher`); + if (process.env.CASE === "owned") { + const result = await tool.execute("owned-idempotence", {}, undefined, undefined, {}); + if (!result.content[0].text.includes("already has an arm child")) { + throw new Error(`owned re-arm was not idempotent: ${result.content[0].text}`); + } + const armCount = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split(/\n/).length; + if (armCount !== 1) throw new Error(`owned session started ${armCount} arm children`); + } +} +await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {}); +EOF + ) || status=$? + if [ -n "$other_pid" ]; then + kill "$other_pid" 2>/dev/null || true + wait "$other_pid" 2>/dev/null || true + fi + if [ "$status" -ne 0 ]; then + fail "Pi resume integration failed for the $scenario lock state: $out" + fi + [ -z "$out" ] || fail "Pi resume $scenario integration printed output: $out" + done + pass "Pi session_start reclaims missing/dead locks, refuses live other, and idempotently arms owned locks" +} + +test_pi_extension_distinguishes_lock_refusal_states() { + local mode expected repo home plugin out status + for mode in other failure changed; do + repo="$TMP_ROOT/pi-lock-message-$mode-root" + home="$TMP_ROOT/pi-lock-message-$mode-home" + mkdir -p "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +printf 'unexpected arm\n' >&2 +exit 9 +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + case "$mode" in + other) expected="read-only - a verified live firstmate session owns this home" ;; + failure) expected="lock acquisition failed - session lock is missing" ;; + changed) expected="lock ownership changed during resume - a verified live firstmate session now owns this home" ;; + esac + status=0 + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_LOCK_FIXTURE_MODE="$mode" EXPECTED="$expected" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +let tool = null; +const pi = { + on() {}, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async () => {}, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const result = await tool.execute("lock-message", {}, undefined, undefined, {}); +if (result.details?.ok !== false) throw new Error(`refusal unexpectedly succeeded: ${JSON.stringify(result)}`); +if (!result.content[0].text.includes(process.env.EXPECTED)) { + throw new Error(`expected '${process.env.EXPECTED}', got '${result.content[0].text}'`); +} +EOF + ) || status=$? + expect_code 0 "$status" "Pi watcher must distinguish the $mode lock refusal" + [ -z "$out" ] || fail "Pi lock-message $mode test printed output: $out" + done + pass "Pi watcher distinguishes verified competitor, acquisition failure, and changed ownership" +} + test_opencode_primary_watch_plugin_static_wiring() { local plugin module_boundary text plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js" @@ -741,6 +982,8 @@ test_pi_extension_reports_external_healthy_watcher test_pi_tool_returns_agent_tool_result test_pi_process_exit_cleanup_listener_lifecycle test_pi_process_exit_cleanup_stops_arm_child +test_pi_resume_session_recovers_only_reclaimable_locks +test_pi_extension_distinguishes_lock_refusal_states test_opencode_primary_watch_plugin_static_wiring test_opencode_plugin_package_boundary_is_explicit_esm test_opencode_primary_watch_plugin_uses_effective_state_home diff --git a/tests/fm-session-lock.test.sh b/tests/fm-session-lock.test.sh new file mode 100755 index 000000000..b1327bc78 --- /dev/null +++ b/tests/fm-session-lock.test.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# Subprocess tests for the per-home firstmate session lock owner. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-session-lock) +LOCK_SCRIPT="$ROOT/bin/fm-lock.sh" +LIVE_PIDS=() + +cleanup() { + 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 EXIT + +start_live_process() { + sleep 300 & + LIVE_PID=$! + LIVE_PIDS+=("$LIVE_PID") +} + +install_fake_ps() { + local fakebin=$1 + mkdir -p "$fakebin" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +pid= +previous= +for argument in "$@"; do + if [ "$previous" = "-p" ]; then + pid=$argument + break + fi + previous=$argument +done + +comm_for_pid() { + if [ "$1" = "${FM_FAKE_ME_PID:-}" ]; then + printf '%s\n' "${FM_FAKE_ME_COMM:-/usr/local/bin/pi}" + elif [ "$1" = "${FM_FAKE_OTHER_PID:-}" ]; then + printf '%s\n' "${FM_FAKE_OTHER_COMM:-/usr/local/bin/pi}" + else + printf '/bin/bash\n' + fi +} + +args_for_pid() { + if [ "$1" = "${FM_FAKE_ME_PID:-}" ]; then + printf '%s\n' "${FM_FAKE_ME_ARGS:-pi}" + elif [ "$1" = "${FM_FAKE_OTHER_PID:-}" ]; then + printf '%s\n' "${FM_FAKE_OTHER_ARGS:-pi}" + else + printf 'bash fm-lock.sh\n' + fi +} + +case "$*" in + *"comm="*) + if [ -n "${FM_FAKE_BARRIER_DIR:-}" ] && [ "$pid" = "${FM_FAKE_ME_PID:-}" ]; then + : > "$FM_FAKE_BARRIER_DIR/$pid" + attempts=0 + while [ "$(find "$FM_FAKE_BARRIER_DIR" -type f | wc -l | tr -d ' ')" -lt 2 ]; do + sleep 0.01 + attempts=$((attempts + 1)) + [ "$attempts" -lt 500 ] || exit 8 + done + fi + comm_for_pid "$pid" + ;; + *"args="*) args_for_pid "$pid" ;; + *"ppid="*) + if [ "$pid" = "${FM_FAKE_ME_PID:-}" ] || [ "$pid" = "${FM_FAKE_OTHER_PID:-}" ]; then + printf '1\n' + else + printf '%s\n' "${FM_FAKE_ME_PID:-1}" + fi + ;; + *) exit 1 ;; +esac +SH + chmod +x "$fakebin/ps" +} + +new_home() { + TEST_HOME="$TMP_ROOT/$1" + TEST_FAKEBIN="$TEST_HOME/fakebin" + mkdir -p "$TEST_HOME/state" + install_fake_ps "$TEST_FAKEBIN" +} + +run_lock() { + env \ + FM_HOME="$TEST_HOME" \ + FM_FAKE_ME_PID="$1" \ + FM_FAKE_OTHER_PID="${2:-}" \ + PATH="$TEST_FAKEBIN:$PATH" \ + "$LOCK_SCRIPT" "${3:-}" +} + +test_live_native_pi_holder_is_protected() { + local out + new_home live-native-pi + start_live_process + printf '%s\n' "$LIVE_PID" > "$TEST_HOME/state/.lock" + out=$(FM_HOME="$TEST_HOME" FM_FAKE_OTHER_PID="$LIVE_PID" PATH="$TEST_FAKEBIN:$PATH" "$LOCK_SCRIPT" status) + assert_contains "$out" "lock: held by live harness pid $LIVE_PID" "native Pi holder was not classified as live" + pass "native Pi comm and argv are classified as a live holder" +} + +test_all_supported_native_harness_names_are_exact() { + local holder harness out + new_home supported-native + start_live_process + holder=$LIVE_PID + printf '%s\n' "$holder" > "$TEST_HOME/state/.lock" + for harness in claude codex opencode pi grok; do + out=$(FM_HOME="$TEST_HOME" FM_FAKE_OTHER_PID="$holder" FM_FAKE_OTHER_COMM="/usr/local/bin/$harness" FM_FAKE_OTHER_ARGS="$harness" PATH="$TEST_FAKEBIN:$PATH" "$LOCK_SCRIPT" status) + assert_contains "$out" "lock: held by live harness pid $holder" "$harness was not classified as a supported native harness" + done + out=$(FM_HOME="$TEST_HOME" FM_FAKE_OTHER_PID="$holder" FM_FAKE_OTHER_COMM=/usr/local/bin/notcodex FM_FAKE_OTHER_ARGS=notcodex PATH="$TEST_FAKEBIN:$PATH" "$LOCK_SCRIPT" status) + assert_contains "$out" "lock: stale" "a native command containing a harness substring was accepted" + pass "all supported native harness names match exactly and substrings do not" +} + +test_live_other_refuses_without_mutation() { + local holder contender before after out status + new_home live-other + start_live_process + holder=$LIVE_PID + start_live_process + contender=$LIVE_PID + printf '%s\n' "$holder" > "$TEST_HOME/state/.lock" + before=$(shasum -a 256 "$TEST_HOME/state/.lock") + status=0 + out=$(run_lock "$contender" "$holder" 2>&1) || status=$? + expect_code 1 "$status" "a live other holder must refuse acquisition" + assert_contains "$out" "another live firstmate session holds the lock" "live-other refusal did not identify the competitor" + after=$(shasum -a 256 "$TEST_HOME/state/.lock") + [ "$before" = "$after" ] || fail "live-other refusal changed the lock bytes" + pass "a verified live other holder remains byte-for-byte unchanged" +} + +test_same_holder_is_idempotent() { + local holder out ownership + new_home same-holder + start_live_process + holder=$LIVE_PID + printf '%s\n' "$holder" > "$TEST_HOME/state/.lock" + out=$(run_lock "$holder") + assert_contains "$out" "lock acquired: harness pid $holder" "same-holder acquisition did not remain successful" + [ "$(cat "$TEST_HOME/state/.lock")" = "$holder" ] || fail "same-holder acquisition changed ownership" + ownership=$(run_lock "$holder" "" ownership) + [ "$ownership" = "owned" ] || fail "same-holder ownership reported '$ownership'" + pass "same-holder acquisition and ownership inspection are idempotent" +} + +test_dead_holder_is_reclaimed() { + local contender ownership + new_home dead-holder + start_live_process + contender=$LIVE_PID + printf '999999\n' > "$TEST_HOME/state/.lock" + run_lock "$contender" >/dev/null + [ "$(cat "$TEST_HOME/state/.lock")" = "$contender" ] || fail "dead holder was not replaced by the contender" + ownership=$(run_lock "$contender" "" ownership) + [ "$ownership" = "owned" ] || fail "reclaimed lock reported '$ownership'" + pass "a dead holder is reclaimed through the production owner" +} + +test_malformed_lock_fails_closed() { + local contender before after out status ownership + new_home malformed + start_live_process + contender=$LIVE_PID + printf '123\n456\n' > "$TEST_HOME/state/.lock" + before=$(shasum -a 256 "$TEST_HOME/state/.lock") + status=0 + out=$(run_lock "$contender" 2>&1) || status=$? + expect_code 1 "$status" "malformed lock must refuse acquisition" + assert_contains "$out" "malformed" "malformed lock refusal was not explicit" + after=$(shasum -a 256 "$TEST_HOME/state/.lock") + [ "$before" = "$after" ] || fail "malformed lock refusal changed the lock bytes" + ownership=$(run_lock "$contender" "" ownership) + [ "$ownership" = "malformed" ] || fail "malformed ownership reported '$ownership'" + pass "malformed lock content fails closed without mutation" +} + +test_no_harness_ancestry_refuses() { + local out status + new_home no-harness + status=0 + out=$(FM_HOME="$TEST_HOME" FM_FAKE_ME_PID="" PATH="$TEST_FAKEBIN:$PATH" "$LOCK_SCRIPT" 2>&1) || status=$? + expect_code 1 "$status" "missing harness ancestry must refuse acquisition" + assert_contains "$out" "cannot locate harness process in ancestry" "missing ancestry refusal was not explicit" + assert_absent "$TEST_HOME/state/.lock" "missing ancestry created a lock" + pass "acquisition without a verified harness ancestor fails closed" +} + +test_bare_interpreter_requires_exact_script_basename() { + local holder out + new_home bare-interpreter + start_live_process + holder=$LIVE_PID + printf '%s\n' "$holder" > "$TEST_HOME/state/.lock" + out=$(FM_HOME="$TEST_HOME" FM_FAKE_OTHER_PID="$holder" FM_FAKE_OTHER_COMM=/usr/bin/node FM_FAKE_OTHER_ARGS='node /opt/homebrew/bin/pi --print' PATH="$TEST_FAKEBIN:$PATH" "$LOCK_SCRIPT" status) + assert_contains "$out" "lock: held by live harness pid $holder" "exact interpreter script basename was not recognized" + out=$(FM_HOME="$TEST_HOME" FM_FAKE_OTHER_PID="$holder" FM_FAKE_OTHER_COMM=/usr/bin/node FM_FAKE_OTHER_ARGS='node /tmp/notcodex-helper.js pi' PATH="$TEST_FAKEBIN:$PATH" "$LOCK_SCRIPT" status) + assert_contains "$out" "lock: stale" "an arbitrary argv substring or later token matched as a harness" + pass "bare interpreter classification uses only the exact script basename" +} + +test_concurrent_contenders_have_one_winner() { + local first second barrier rc_one rc_two winners lock_pid + new_home concurrent + start_live_process + first=$LIVE_PID + start_live_process + second=$LIVE_PID + barrier="$TEST_HOME/barrier" + mkdir -p "$barrier" + ( + status=0 + FM_HOME="$TEST_HOME" FM_FAKE_ME_PID="$first" FM_FAKE_OTHER_PID="$second" FM_FAKE_BARRIER_DIR="$barrier" PATH="$TEST_FAKEBIN:$PATH" "$LOCK_SCRIPT" > "$TEST_HOME/one.out" 2>&1 || status=$? + printf '%s\n' "$status" > "$TEST_HOME/one.rc" + ) & + one_job=$! + ( + status=0 + FM_HOME="$TEST_HOME" FM_FAKE_ME_PID="$second" FM_FAKE_OTHER_PID="$first" FM_FAKE_BARRIER_DIR="$barrier" PATH="$TEST_FAKEBIN:$PATH" "$LOCK_SCRIPT" > "$TEST_HOME/two.out" 2>&1 || status=$? + printf '%s\n' "$status" > "$TEST_HOME/two.rc" + ) & + two_job=$! + wait "$one_job" + wait "$two_job" + rc_one=$(cat "$TEST_HOME/one.rc") + rc_two=$(cat "$TEST_HOME/two.rc") + winners=0 + [ "$rc_one" -eq 0 ] && winners=$((winners + 1)) + [ "$rc_two" -eq 0 ] && winners=$((winners + 1)) + [ "$winners" -eq 1 ] || fail "concurrent contenders produced $winners winners (rcs $rc_one/$rc_two)" + lock_pid=$(cat "$TEST_HOME/state/.lock") + [ "$lock_pid" = "$first" ] || [ "$lock_pid" = "$second" ] || fail "concurrent winner wrote unexpected pid '$lock_pid'" + pass "serialized concurrent contenders produce exactly one winner" +} + +test_live_native_pi_holder_is_protected +test_all_supported_native_harness_names_are_exact +test_live_other_refuses_without_mutation +test_same_holder_is_idempotent +test_dead_holder_is_reclaimed +test_malformed_lock_fails_closed +test_no_harness_ancestry_refuses +test_bare_interpreter_requires_exact_script_basename +test_concurrent_contenders_have_one_winner diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 3e5d54f82..c7a806e5c 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -758,6 +758,10 @@ test_pi_extension_forces_followup() { assert_contains "$content" 'session-start operating block' "pi extension must use harness-neutral repair wording" assert_contains "$content" '.pi-turnend-extension-loaded' "pi extension must write its loaded marker for session-start diagnostics" assert_contains "$content" 'lockOwnership' "pi extension loaded marker must respect the session lock" + assert_contains "$content" 'spawnSync(lockScript, ["ownership"]' "pi extension must use the lock owner's read-only API" + assert_not_contains "$content" 'function parentPid' "pi extension kept a duplicate ancestry classifier" + assert_not_contains "$content" 'function pidAlive' "pi extension kept a duplicate holder-liveness classifier" + assert_not_contains "$content" "readFileSync(\`\${state}/.lock\`" "pi extension still classifies lock bytes itself" assert_contains "$content" 'const command = String((event.input as { command?: unknown })?.command ?? "")' "pi extension changed bash command extraction for the PreToolUse contract" assert_contains "$content" 'runPretoolCheck(command)' "pi extension changed the PreToolUse checker invocation" assert_contains "$content" 'return { block: true, reason:' "pi extension changed the checker exit-2 block result" From de0ab5d3cda50ecce34c2f8627917b629e470856 Mon Sep 17 00:00:00 2001 From: hey137 Date: Thu, 16 Jul 2026 23:42:39 +0800 Subject: [PATCH 02/13] =?UTF-8?q?no-mistakes(test):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E5=88=86=E7=B1=BB=E4=B8=8E=20watcher=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/fm-afk-inject-e2e.test.sh | 9 ++++----- tests/fm-afk-inject-herdr-e2e.test.sh | 9 ++++----- tests/fm-watcher-lock.test.sh | 11 +++++++++-- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/tests/fm-afk-inject-e2e.test.sh b/tests/fm-afk-inject-e2e.test.sh index be029d6c1..a5a41d57d 100755 --- a/tests/fm-afk-inject-e2e.test.sh +++ b/tests/fm-afk-inject-e2e.test.sh @@ -98,11 +98,10 @@ redraw() { } submit_line() { local _line=$_buf _c _hex - if [ "${_line:0:1}" = "$MARK" ]; then - _c="injection" - else - _c="user" - fi + case "$_line" in + "$MARK"*) _c="injection" ;; + *) _c="user" ;; + esac _hex=$(printf '%s' "$_line" | od -An -tx1 | tr -d ' \n') printf '%s\t%s\t%s\n' "$_hex" "$_line" "$_c" >> "$LOG" _buf= diff --git a/tests/fm-afk-inject-herdr-e2e.test.sh b/tests/fm-afk-inject-herdr-e2e.test.sh index 5e48ab43e..97a49020d 100755 --- a/tests/fm-afk-inject-herdr-e2e.test.sh +++ b/tests/fm-afk-inject-herdr-e2e.test.sh @@ -164,11 +164,10 @@ redraw() { } submit_line() { local _line=$_buf _c _hex - if [ "${_line:0:1}" = "$MARK" ]; then - _c="injection" - else - _c="user" - fi + case "$_line" in + "$MARK"*) _c="injection" ;; + *) _c="user" ;; + esac _hex=$(printf '%s' "$_line" | od -An -tx1 | tr -d ' \n') printf '%s\t%s\t%s\n' "$_hex" "$_line" "$_c" >> "$LOG" _buf= diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 36e1f8dd4..0fee2befd 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -437,14 +437,21 @@ test_watch_restart_rejects_reused_pid() { } test_watch_restart_reports_healthy_peer_without_attaching() { - local dir state fakebin out peer identity armpid status + 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 'const fs = require("fs"); process.on("SIGTERM", () => {}); fs.writeFileSync(process.argv[1], "ready\n"); setTimeout(() => {}, 300000)' "$ready" & peer=$! + i=0 + while [ "$i" -lt 50 ] && [ ! -s "$ready" ]; do + sleep 0.1 + i=$((i + 1)) + done + [ -s "$ready" ] || fail "TERM-resistant peer did not become ready" 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 5cb65583174100f40c7361c603d660c2df9f37a9 Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 17 Jul 2026 00:13:10 +0800 Subject: [PATCH 03/13] no-mistakes(document): Align Pi resume lock documentation --- .agents/skills/harness-adapters/SKILL.md | 4 ++-- docs/turnend-guard.md | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 1e44bff54..ad109d5c7 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -211,11 +211,11 @@ The decision persists per path in `~/.pi/agent/trust.json`, so later spawns in t The extension must listen for pi's `turn_end` event, not `agent_end`, so the watcher wakes after each completed turn instead of only when the whole agent run exits. Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection env marker. -**Primary-session guard fact (verified 2026-07-09, Pi 0.80.5).** +**Primary-session guard fact (verified 2026-07-09, Pi 0.80.5; resume fixture verified 2026-07-16).** The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens for logical-run `agent_settled`, not per-tool-loop `turn_end`, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one guarded follow-up when `bin/fm-turnend-guard.sh` returns 2. Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing. Pi's primary watcher protocol also requires the tracked `.pi/extensions/fm-primary-pi-watch.ts` extension, same trust-once discovery as the turn-end guard. -The model arms through `fm_watch_arm_pi`, never a foreground bash arm; the watcher tool result and clean-exit fallback are owned by `docs/supervision-protocols/pi.md`. +The watcher extension performs the initial `session_start` arm; after a wake, the model re-arms through `fm_watch_arm_pi`, never a foreground bash arm, with the full recovery and clean-exit protocol owned by `docs/supervision-protocols/pi.md`. `bin/fm-session-start.sh` reports when the live Pi session has not loaded both the turn-end guard and watcher extensions, and points at plain `pi` after project trust as the fix, with `-e` as a trust-free fallback. When a secondmate is launched on Pi, `fm-spawn.sh --secondmate` launches Pi with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 8251aac8d..d80b1ee55 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -147,11 +147,8 @@ No Herdr command was issued and no fleet state was touched; the experiment wrote ### 2026-07-16: native Pi resume lock recovery -Pi 0.80.7 native resume restores the conversation without rerunning `bin/fm-session-start.sh`, so the watcher extension now checks the production lock owner during `session_start`. -The production owner matches native `pi` only by exact process basename and inspects argv only for the explicitly allowed bare interpreters `node`, `nodejs`, `python`, and `python3`. -Session-lock publication is serialized through the portable lock primitive in `bin/fm-wake-lib.sh`, while `bin/fm-lock.sh` remains the only writer of `state/.lock`. -The watcher calls acquisition only for `missing` or `dead`, reads ownership again, and starts supervision only after observing `owned`. -A verified live other holder and malformed lock remain byte-for-byte unchanged. +Pi 0.80.7 native resume restores the conversation without rerunning `bin/fm-session-start.sh`; `docs/supervision-protocols/pi.md` owns the resulting lock-recovery and watcher-arm protocol. +`bin/fm-lock.sh` remains the sole production session-lock owner, and its header owns classification and mutation mechanics. Command run: `bash tests/fm-session-lock.test.sh`. Observed output covered exact native names for Claude, Codex, OpenCode, Pi, and Grok, same-holder idempotence, dead reclaim, malformed refusal, missing ancestry, exact interpreter script matching, and one winner from two synchronized contenders. @@ -168,4 +165,4 @@ No Herdr lifecycle command was required for these subprocess fixtures. `tests/fm-session-lock.test.sh` covers the shared session-lock classifier and serialized mutation contract for every supported primary harness. `tests/fm-pi-watch-extension.test.sh` covers resume recovery through the production owner and the shared read-only ownership API used by both Pi extensions. The default behavior suite does not invoke live language-model harnesses. -`FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the isolated interactive Pi regression recorded above. +`FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the current isolated interactive Pi regression, whose post-change expectations are owned by `docs/supervision-protocols/pi.md`. From 9a5c8ca8cd993a24013c8130d9eec29112f31f5b Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 17 Jul 2026 10:30:35 +0800 Subject: [PATCH 04/13] no-mistakes(review): Validate native Pi resume lock recovery end-to-end --- docs/supervision-protocols/pi.md | 8 +- tests/fm-pi-primary-live-e2e.test.sh | 105 +++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 27711d386..2bfc4facb 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -35,4 +35,10 @@ Command run: `bash tests/fm-session-lock.test.sh`. Observed output included `ok - native Pi comm and argv are classified as a live holder`, `ok - a verified live other holder remains byte-for-byte unchanged`, and `ok - serialized concurrent contenders produce exactly one winner`. Command run: `bash tests/fm-pi-watch-extension.test.sh`. Observed output included `ok - Pi session_start reclaims missing/dead locks, refuses live other, and idempotently arms owned locks` and `ok - Pi watcher distinguishes verified competitor, acquisition failure, and changed ownership`. -The opt-in interactive fixture now expects native Pi ownership plus automatic first arm on `session_start`; it remains isolated behind `FM_PI_LIVE_E2E=1`. + +Verification on 2026-07-17 used native Pi 0.80.7 with an isolated saved session, writable copies of the existing Pi authentication and model configuration, `PI_CODING_AGENT_DIR`, `FM_HOME`, project clone, and tmux socket. +Command run: `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh`. +The fixture created a saved session with `--session-id`, exited while leaving the dead native Pi PID in `.lock`, and resumed that exact session with `--session`. +At the boundary before the real watcher arm script ran, the old PID was dead and `.lock` already named the new native Pi process. +Observed output: `ok - Pi 0.80.7 live E2E created, exited, resumed, reclaimed before watcher startup, woke, re-armed, and cleaned up`. +The opt-in interactive fixture remains isolated behind `FM_PI_LIVE_E2E=1`. diff --git a/tests/fm-pi-primary-live-e2e.test.sh b/tests/fm-pi-primary-live-e2e.test.sh index 5a6dba1e6..cdaaff14e 100755 --- a/tests/fm-pi-primary-live-e2e.test.sh +++ b/tests/fm-pi-primary-live-e2e.test.sh @@ -18,6 +18,10 @@ LAB="$ROOT/.pi-live-e2e.$$" PROJECT="$LAB/project" HOME_DIR="$LAB/fmhome" PI_DIR="$LAB/pi-agent" +PI_SESSION_DIR="$PI_DIR/sessions" +PI_SESSION_ID=11111111-1111-4111-8111-111111111111 +PI_LAUNCHER="$LAB/run-pi-resume.sh" +PI_CONFIG_SOURCE=${FM_PI_LIVE_E2E_AGENT_DIR:-$HOME/.pi/agent} PI_VERSION=$(pi --version) fail() { @@ -25,6 +29,11 @@ fail() { exit 1 } +[ "$PI_VERSION" = 0.80.7 ] || fail "live resume regression requires Pi 0.80.7, found $PI_VERSION" +for required in auth.json models.json settings.json; do + [ -f "$PI_CONFIG_SOURCE/$required" ] || fail "live resume regression requires $PI_CONFIG_SOURCE/$required" +done + capture() { "$TMUX" -L "$SOCKET" capture-pane -p -t "$SESSION" -S -600 2>/dev/null || true } @@ -65,6 +74,16 @@ wait_for_file() { return 1 } +wait_for_path_absent() { + local path=$1 attempts=${2:-120} i=0 + while [ "$i" -lt "$attempts" ]; do + [ ! -e "$path" ] && return 0 + sleep 0.5 + i=$((i + 1)) + done + return 1 +} + lab_pid_is_safe() { local pid=$1 command command=$(ps -p "$pid" -o command= 2>/dev/null || true) @@ -116,21 +135,92 @@ git clone -q "$ROOT" "$PROJECT" cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$PROJECT/.pi/extensions/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$PROJECT/.pi/extensions/fm-primary-turnend-guard.ts" cp "$ROOT/bin/fm-supervision-instructions.sh" "$PROJECT/bin/fm-supervision-instructions.sh" -mkdir -p "$HOME_DIR/state" "$HOME_DIR/config" "$PI_DIR" +mkdir -p "$HOME_DIR/state" "$HOME_DIR/config" "$PI_DIR" "$PI_SESSION_DIR" +cp "$PI_CONFIG_SOURCE/auth.json" "$PI_CONFIG_SOURCE/models.json" "$PI_CONFIG_SOURCE/settings.json" "$PI_DIR/" + +mv "$PROJECT/bin/fm-watch-arm.sh" "$PROJECT/bin/fm-watch-arm-live-e2e-real.sh" +cat > "$PROJECT/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +set -u +state=${FM_STATE_OVERRIDE:-$FM_HOME/state} +expected="$state/.resume-expected-old-pid" +observed="$state/.resume-lock-before-watcher" +if [ -f "$expected" ]; then + old=$(cat "$expected") + current=$(cat "$state/.lock") + old_state=dead + kill -0 "$old" 2>/dev/null && old_state=live + current_comm=$(ps -p "$current" -o comm= 2>/dev/null || true) + current_comm=${current_comm##*/} + temporary=$(mktemp "$state/.resume-lock-before-watcher.XXXXXX") + printf '%s\n%s\n%s\n' "$old_state" "$current" "$current_comm" > "$temporary" + mv "$temporary" "$observed" + [ "$old_state" = dead ] && [ "$current" != "$old" ] && [ "$current_comm" = pi ] || exit 97 +fi +exec "$(dirname "$0")/fm-watch-arm-live-e2e-real.sh" "$@" +SH +chmod +x "$PROJECT/bin/fm-watch-arm.sh" + +cat > "$PI_LAUNCHER" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n' "$$" > "$FM_HOME/state/.lock" +pi --session-dir "$PI_SESSION_DIR" --session-id "$PI_SESSION_ID" +first_rc=$? +printf 'FIRST_PI_EXIT=%s\n' "$first_rc" +[ "$first_rc" -eq 0 ] || exit "$first_rc" +while [ ! -f "$FM_HOME/state/.resume-now" ]; do sleep 0.1; done +printf 'PI_RESUME_STARTING=%s\n' "$PI_SESSION_ID" +pi --session-dir "$PI_SESSION_DIR" --session "$PI_SESSION_ID" +resumed_rc=$? +printf 'RESUMED_PI_EXIT=%s\n' "$resumed_rc" +[ "$resumed_rc" -eq 0 ] || exit "$resumed_rc" +sleep 300 +SH +chmod +x "$PI_LAUNCHER" "$TMUX" -L "$SOCKET" new-session -d -s "$SESSION" -c "$PROJECT" \ - "env PI_CODING_AGENT_DIR='$PI_DIR' FM_HOME='$HOME_DIR' FM_ROOT_OVERRIDE='$PROJECT' FM_POLL=1 FM_SIGNAL_GRACE=0 FM_HEARTBEAT=600 PI_OFFLINE=1 bash -lc 'printf \"%s\\n\" \"\$\$\" > \"\$FM_HOME/state/.lock\"; pi; rc=\$?; printf \"PI_EXIT=%s\\n\" \"\$rc\"; sleep 300'" + "env PI_CODING_AGENT_DIR='$PI_DIR' PI_SESSION_DIR='$PI_SESSION_DIR' PI_SESSION_ID='$PI_SESSION_ID' FM_HOME='$HOME_DIR' FM_ROOT_OVERRIDE='$PROJECT' FM_POLL=1 FM_SIGNAL_GRACE=0 FM_HEARTBEAT=600 PI_OFFLINE=1 '$PI_LAUNCHER'" wait_for_text "Trust project folder?" 40 || fail "Pi trust prompt did not appear" "$TMUX" -L "$SOCKET" send-keys -t "$SESSION" Enter wait_for_text "fm-primary-turnend-guard.ts" 60 || fail "Pi primary extensions did not load" wait_for_file "$HOME_DIR/state/.watch.lock/pid" 60 || fail "Pi session_start did not automatically arm supervision" -session_lock_pid=$(cat "$HOME_DIR/state/.lock") -session_lock_comm=$(ps -p "$session_lock_pid" -o comm= 2>/dev/null || true) -[ "${session_lock_comm##*/}" = pi ] || fail "Pi session_start did not replace the shell fixture lock with the native Pi owner" +initial_pi_pid=$(cat "$HOME_DIR/state/.lock") +initial_pi_comm=$(ps -p "$initial_pi_pid" -o comm= 2>/dev/null || true) +[ "${initial_pi_comm##*/}" = pi ] || fail "Pi session_start did not replace the shell fixture lock with the native Pi owner" +initial_watcher_pid=$(cat "$HOME_DIR/state/.watch.lock/pid") +initial_arm_pid=$(ps -p "$initial_watcher_pid" -o ppid= | tr -d ' ') +[ -n "$initial_arm_pid" ] || fail "initial watcher parent was not live" send_prompt "Use the bash tool to run printf PI_E2E_BASH_ONE. Then reply exactly BASH-ONE." wait_for_exact_line "BASH-ONE" || fail "first bash turn did not complete" +session_file=$(find "$PI_SESSION_DIR" -maxdepth 1 -type f -name "*_${PI_SESSION_ID}.jsonl" | head -1) +[ -n "$session_file" ] || fail "Pi did not persist the session selected for native resume" + +"$TMUX" -L "$SOCKET" send-keys -t "$SESSION" -l '/quit' +sleep 1 +"$TMUX" -L "$SOCKET" send-keys -t "$SESSION" Enter +wait_for_text "FIRST_PI_EXIT=0" 60 || fail "initial Pi session did not exit cleanly" +wait_pid_dead "$initial_pi_pid" || fail "initial native Pi owner survived clean exit" +wait_pid_dead "$initial_watcher_pid" || fail "initial watcher survived clean Pi exit" +wait_pid_dead "$initial_arm_pid" || fail "initial arm child survived clean Pi exit" +wait_for_path_absent "$HOME_DIR/state/.watch.lock" 60 || fail "initial watcher lock survived clean Pi exit" +[ "$(cat "$HOME_DIR/state/.lock")" = "$initial_pi_pid" ] || fail "initial native Pi PID was not left in the session lock" + +printf '%s\n' "$initial_pi_pid" > "$HOME_DIR/state/.resume-expected-old-pid" +: > "$HOME_DIR/state/.resume-now" +wait_for_text "PI_RESUME_STARTING=$PI_SESSION_ID" 60 || fail "native Pi resume command did not start" +wait_for_file "$HOME_DIR/state/.resume-lock-before-watcher" 60 || fail "resume watcher boundary was not observed" +resume_old_state=$(sed -n '1p' "$HOME_DIR/state/.resume-lock-before-watcher") +resumed_pi_pid=$(sed -n '2p' "$HOME_DIR/state/.resume-lock-before-watcher") +resumed_pi_comm=$(sed -n '3p' "$HOME_DIR/state/.resume-lock-before-watcher") +[ "$resume_old_state" = dead ] || fail "old native Pi owner was still live before resumed watcher startup" +[ "$resumed_pi_pid" != "$initial_pi_pid" ] || fail "native Pi resume did not replace the dead session lock owner" +[ "$resumed_pi_comm" = pi ] || fail "resumed session lock owner was not native Pi" +[ "$(cat "$HOME_DIR/state/.lock")" = "$resumed_pi_pid" ] || fail "session lock changed after resume reclamation" +wait_for_file "$HOME_DIR/state/.watch.lock/pid" 60 || fail "resumed Pi did not arm supervision after lock reclamation" + send_prompt "Use the read tool to read the first five lines of README.md. Then reply exactly READ-ONE." wait_for_exact_line "READ-ONE" || fail "read turn did not complete" send_prompt "Use the bash tool to run printf PI_E2E_BASH_TWO. Then reply exactly BASH-TWO." @@ -161,8 +251,9 @@ arm_pid=$(ps -p "$watcher_pid" -o ppid= | tr -d ' ') "$TMUX" -L "$SOCKET" send-keys -t "$SESSION" -l '/quit' sleep 1 "$TMUX" -L "$SOCKET" send-keys -t "$SESSION" Enter -wait_for_text "PI_EXIT=0" 60 || fail "Pi did not exit cleanly" +wait_for_text "RESUMED_PI_EXIT=0" 60 || fail "resumed Pi did not exit cleanly" +wait_pid_dead "$resumed_pi_pid" || fail "resumed native Pi owner survived clean exit" wait_pid_dead "$watcher_pid" || fail "watcher child survived clean Pi exit" wait_pid_dead "$arm_pid" || fail "arm child survived clean Pi exit" -printf 'ok - Pi %s live E2E reclaimed the native lock, auto-armed, woke, re-armed, and cleaned up on exit\n' "$PI_VERSION" +printf 'ok - Pi %s live E2E created, exited, resumed, reclaimed before watcher startup, woke, re-armed, and cleaned up\n' "$PI_VERSION" From 6737400baa0db88ee8bf8010dcefdcddd1fa1bcb Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 17 Jul 2026 11:05:39 +0800 Subject: [PATCH 05/13] no-mistakes(review): Harden Pi resume scope, readiness, and isolated E2E --- .agents/skills/harness-adapters/SKILL.md | 3 +- .pi/extensions/fm-primary-pi-watch.ts | 130 +++++++++++++----- bin/fm-primary-scope.sh | 31 +++++ bin/fm-turnend-guard.sh | 49 +------ docs/scripts.md | 1 + docs/supervision-protocols/pi.md | 9 +- docs/turnend-guard.md | 4 +- tests/fm-pi-primary-live-e2e.test.sh | 105 ++++++++++++--- tests/fm-pi-watch-extension.test.sh | 165 ++++++++++++++++++++++- tests/fm-turnend-guard.test.sh | 3 +- 10 files changed, 388 insertions(+), 112 deletions(-) create mode 100755 bin/fm-primary-scope.sh diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index ad109d5c7..4e78b5be2 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -215,7 +215,8 @@ Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection e The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens for logical-run `agent_settled`, not per-tool-loop `turn_end`, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one guarded follow-up when `bin/fm-turnend-guard.sh` returns 2. Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing. Pi's primary watcher protocol also requires the tracked `.pi/extensions/fm-primary-pi-watch.ts` extension, same trust-once discovery as the turn-end guard. -The watcher extension performs the initial `session_start` arm; after a wake, the model re-arms through `fm_watch_arm_pi`, never a foreground bash arm, with the full recovery and clean-exit protocol owned by `docs/supervision-protocols/pi.md`. +The watcher extension performs the initial `session_start` arm; after a wake, the model re-arms through `fm_watch_arm_pi`, which waits for verified watcher readiness, never a foreground bash arm, with the full recovery and clean-exit protocol owned by `docs/supervision-protocols/pi.md`. +Before lock recovery it requires the shared primary-home scope, and in away mode it may recover the session lock but leaves watcher supervision with the sub-supervisor. `bin/fm-session-start.sh` reports when the live Pi session has not loaded both the turn-end guard and watcher extensions, and points at plain `pi` after project trust as the fix, with `-e` as a trust-free fallback. When a secondmate is launched on Pi, `fm-spawn.sh --secondmate` launches Pi with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 5e0877626..499725fdb 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -1,7 +1,7 @@ // Firstmate primary watcher bridge for Pi. import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -23,10 +23,13 @@ const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`; const armScript = `${fmRoot}/bin/fm-watch-arm.sh`; const lockScript = `${fmRoot}/bin/fm-lock.sh`; +const scopeScript = `${fmRoot}/bin/fm-primary-scope.sh`; const marker = `${state}/.pi-watch-extension-loaded`; const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; let child: any = null; +let armReady = false; +let armReadiness: Promise | null = null; let seq = 0; function lockEnvironment() { @@ -48,13 +51,29 @@ function lockOwnership(): LockOwnership { return "unknown"; } -function markLoaded(): void { +function isPrimaryHome(): boolean { + const result = spawnSync(scopeScript, [], { + encoding: "utf8", + env: { + ...lockEnvironment(), + FM_ROOT_OVERRIDE: root, + }, + }); + return result.status === 0; +} + +function markLoadedForPrimary(): void { const ownership = lockOwnership(); if (ownership === "other" || ownership === "malformed" || ownership === "unknown") return; mkdirSync(state, { recursive: true }); writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); } +function markLoaded(): void { + if (!isPrimaryHome()) return; + markLoadedForPrimary(); +} + function actionableLine(output: string): string { const lines = output.split(/\r?\n/); return lines.find((line) => /^(signal:|stale:|check:|heartbeat($|:))/.test(line)) || ""; @@ -74,6 +93,8 @@ export default function (pi: ExtensionAPI) { function stopArm(): void { if (child) child.kill("SIGTERM"); child = null; + armReady = false; + armReadiness = null; } const cleanupOnProcessExit = () => { @@ -88,7 +109,10 @@ export default function (pi: ExtensionAPI) { ); } - function startArm(): ArmResult { + async function startArm(): Promise { + if (!isPrimaryHome()) { + return { ok: false, message: "watcher: inactive - current checkout is not a primary firstmate home" }; + } let ownership = lockOwnership(); let acquisitionError = ""; let attemptedAcquisition = false; @@ -108,8 +132,14 @@ export default function (pi: ExtensionAPI) { const detail = acquisitionError ? ` (${acquisitionError})` : ""; return { ok: false, message: `watcher: lock acquisition failed - session lock is ${ownership}${detail}` }; } - markLoaded(); - if (child) return { ok: true, message: "watcher: healthy - Pi extension already has an arm child" }; + markLoadedForPrimary(); + if (existsSync(`${state}/.afk`)) { + return { ok: true, message: "watcher: away mode - sub-supervisor owns supervision" }; + } + if (child) { + if (armReady) return { ok: true, message: "watcher: healthy - Pi extension already has an arm child" }; + if (armReadiness) return armReadiness; + } const id = ++seq; const env = { ...process.env, @@ -118,43 +148,73 @@ export default function (pi: ExtensionAPI) { FM_CONFIG_OVERRIDE: config, FM_WATCH_ARM_SCRIPT: armScript, }; - child = spawn("bash", ["-lc", "config_dir=\"${FM_CONFIG_OVERRIDE:-$FM_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$FM_WATCH_ARM_SCRIPT\" --restart"], { + const armChild = spawn("bash", ["-lc", "config_dir=\"${FM_CONFIG_OVERRIDE:-$FM_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$FM_WATCH_ARM_SCRIPT\" --restart"], { cwd: fmRoot, env, stdio: ["ignore", "pipe", "pipe"], }); + child = armChild; + armReady = false; let stdout = ""; let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.on("close", async (code: number | null) => { - child = null; - const reason = actionableLine(`${stdout}\n${stderr}`); - const failure = reason ? "" : failureLine(stdout, stderr, code); - if (!reason && !failure) return; - try { - await sendWake(reason || failure); - } catch { - // Pi owns delivery errors; fail open so the extension never wedges the session. - } - }); - child.on("error", async (error: Error) => { - child = null; - try { - await sendWake(`watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`); - } catch { - // Fail open. - } + const readiness = new Promise((resolveReadiness) => { + let settled = false; + const settle = (result: ArmResult) => { + if (settled) return; + settled = true; + armReady = result.ok; + resolveReadiness(result); + }; + armChild.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + if (stdout.split(/\r?\n/).some((line) => /^watcher: started\b/.test(line))) { + settle({ ok: true, message: `watcher: started Pi extension arm child ${id}` }); + } + }); + armChild.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + armChild.on("close", async (code: number | null) => { + if (child === armChild) { + child = null; + armReady = false; + armReadiness = null; + } + const reason = actionableLine(`${stdout}\n${stderr}`); + const failure = reason ? "" : failureLine(stdout, stderr, code); + settle({ + ok: false, + message: failure || `watcher: FAILED - Pi extension arm child ${id} exited before confirming watcher readiness`, + }); + if (!reason && !failure) return; + try { + await sendWake(reason || failure); + } catch { + // Pi owns delivery errors; fail open so the extension never wedges the session. + } + }); + armChild.on("error", async (error: Error) => { + if (child === armChild) { + child = null; + armReady = false; + armReadiness = null; + } + const failure = `watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`; + settle({ ok: false, message: failure }); + try { + await sendWake(failure); + } catch { + // Fail open. + } + }); }); - return { ok: true, message: `watcher: started Pi extension arm child ${id}` }; + armReadiness = readiness; + return readiness; } - pi.on?.("session_start", (_event, ctx) => { - const result = startArm(); + pi.on?.("session_start", async (_event, ctx) => { + if (!isPrimaryHome()) return; + const result = await startArm(); if (!result.ok) ctx?.ui?.notify?.(result.message, "warning"); }); pi.on?.("session_shutdown", () => { @@ -165,7 +225,7 @@ export default function (pi: ExtensionAPI) { pi.registerCommand?.("fm-watch-arm-pi", { description: "Arm firstmate watcher supervision through the Pi extension instead of foreground bash.", handler: async (_args, ctx) => { - const result = startArm(); + const result = await startArm(); ctx.ui.notify(result.message, result.ok ? "info" : "warning"); }, }); @@ -180,7 +240,7 @@ export default function (pi: ExtensionAPI) { ], parameters: Type.Object({}), execute: async () => { - const result = startArm(); + const result = await startArm(); return { content: [{ type: "text", text: result.message }], details: result, diff --git a/bin/fm-primary-scope.sh b/bin/fm-primary-scope.sh new file mode 100755 index 000000000..5bf8ba7ca --- /dev/null +++ b/bin/fm-primary-scope.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Identify a main or valid secondmate primary home. +# Exits 0 only for an in-scope primary and prints nothing. +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}" + +fm_root_is_secondmate_home() { + local marker="$1/.fm-secondmate-home" id LC_ALL=C + [ -L "$marker" ] && return 1 + [ -f "$marker" ] || return 1 + IFS= read -r id < "$marker" 2>/dev/null || return 1 + id=${id//[[:space:]]/} + [ -n "$id" ] || return 1 + case "$id" in + *[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 +} + +if ! fm_root_is_secondmate_home "$FM_ROOT"; then + GIT_DIR=$(git -C "$FM_ROOT" rev-parse --git-dir 2>/dev/null) || exit 1 + GIT_COMMON_DIR=$(git -C "$FM_ROOT" rev-parse --git-common-dir 2>/dev/null) || exit 1 + [ "$GIT_DIR" = "$GIT_COMMON_DIR" ] || exit 1 +fi +[ -f "$FM_ROOT/AGENTS.md" ] || exit 1 +[ -d "$FM_ROOT/bin" ] || exit 1 +[ -d "$STATE" ] || exit 1 diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index eef947f85..aacb6ccc8 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -2,7 +2,7 @@ # Turn-end guard for any firstmate PRIMARY session: the main home OR a # secondmate's own home. A secondmate runs its own primary firstmate session and # is guarded exactly like the main primary; only child crew/scout worktrees are -# exempt (see the scoping block below and docs/turnend-guard.md). +# exempt (see bin/fm-primary-scope.sh and docs/turnend-guard.md). # # fm-guard.sh (bin/fm-guard.sh) is pull-based: it only warns when some other # supervision script happens to run. A primary session that ends a turn without @@ -61,52 +61,7 @@ command -v jq >/dev/null 2>&1 || exit 0 STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r '.stop_hook_active // false' 2>/dev/null) || exit 0 [ "$STOP_HOOK_ACTIVE" = "true" ] && exit 0 -# Return 0 when $1 (a firstmate root) carries a GENUINE secondmate-home marker. -# bin/fm-home-seed.sh writes .fm-secondmate-home at a seeded secondmate home's -# root (gitignored, so it never propagates into a child worktree); its content is -# the secondmate id. Validate the marker's form so a stray/empty/symlink file -# cannot spoof inclusion and an unmarked child is never guarded by accident: it -# must be a regular (non-symlink) file whose first line, with all whitespace -# removed, is a non-empty id token (letters, digits, dot, underscore, dash only). -# The allowlist is matched under forced C (ASCII) collation - `local LC_ALL=C`, -# restored on return - so a locale-crafted non-ASCII id cannot slip through the -# range match and spoof force-inclusion. This is a deliberately lightweight -# guard-local presence check, distinct from fm-ff-lib.sh's validate_secondmate_home -# (which matches an EXPECTED id and does path-safety); the guard does not source -# that heavier library. -fm_root_is_secondmate_home() { - local marker="$1/.fm-secondmate-home" id LC_ALL=C - [ -L "$marker" ] && return 1 - [ -f "$marker" ] || return 1 - IFS= read -r id < "$marker" 2>/dev/null || return 1 - id=${id//[[:space:]]/} - [ -n "$id" ] || return 1 - case "$id" in - *[!A-Za-z0-9._-]*) return 1 ;; - esac - return 0 -} - -# --- scope precisely to a PRIMARY checkout ---------------------------------- -# A genuinely-marked secondmate home runs its OWN primary firstmate session, so -# force-INCLUDE it as a guarded primary whether treehouse leased it as a linked -# worktree (git-dir != git-common-dir) or it is a git-cloned plain checkout. This -# mirrors the cd-guard's intent that a secondmate's own session is a guarded -# primary. Only an UNMARKED checkout (or one with an invalid marker) falls -# through to the linked-worktree exemption: firstmate hands out crewmate/scout -# task worktrees as genuine linked `git worktree`s (bin/fm-spawn.sh aborts -# otherwise), whose git-dir lives under the parent repo's .git/worktrees/ -# and differs from the common (shared) git-dir, while a main, non-worktree -# checkout has the two equal. Child worktrees never carry the gitignored marker, -# so this exempts them while guarding every real secondmate home. -if ! fm_root_is_secondmate_home "$FM_ROOT"; then - GIT_DIR=$(git -C "$FM_ROOT" rev-parse --git-dir 2>/dev/null) || exit 0 - GIT_COMMON_DIR=$(git -C "$FM_ROOT" rev-parse --git-common-dir 2>/dev/null) || exit 0 - [ "$GIT_DIR" = "$GIT_COMMON_DIR" ] || exit 0 -fi -[ -f "$FM_ROOT/AGENTS.md" ] || exit 0 -[ -d "$FM_ROOT/bin" ] || exit 0 -[ -d "$STATE" ] || exit 0 +"$SCRIPT_DIR/fm-primary-scope.sh" || exit 0 # --- the actual predicate ---------------------------------------------------- # shellcheck source=bin/fm-wake-lib.sh diff --git a/docs/scripts.md b/docs/scripts.md index 0db7010d6..d1cab63ed 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -20,6 +20,7 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | | `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.sh` | Identify a main or valid secondmate primary home | | `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 | | `fm-arm-pretool-check.sh` | Stable PreToolUse transport for the watcher-arm command policy (docs/arm-pretool-check.md) | diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 2bfc4facb..919fb82c6 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -3,10 +3,12 @@ Mode: Pi extension background wake. When this session owns supervision and away mode is not active: 1. Drain first with `bin/fm-wake-drain.sh`. 2. Confirm the Pi primary auto-loaded both project extensions (plain `pi`, after approving project trust once per clone); if not, restart with `-e __FM_PI_TURNEND_EXT__ -e __FM_PI_EXT__` as a trust-free fallback. -3. On `session_start`, the watcher extension reads ownership through `bin/fm-lock.sh ownership`. +3. On `session_start`, the watcher extension first requires `bin/fm-primary-scope.sh` to identify a main or valid secondmate primary home, then reads ownership through `bin/fm-lock.sh ownership`. A missing or dead lock is reacquired only through `bin/fm-lock.sh`, ownership is read again, and supervision starts only after the result is `owned`. A verified live other owner remains unchanged and the extension refuses to arm. + When `state/.afk` exists, lock recovery still completes but the extension does not start or restart a watcher because the sub-supervisor owns supervision. 4. After each watcher wake, re-arm supervision with the `fm_watch_arm_pi` tool. + The tool returns success only after `fm-watch-arm.sh` verifies and reports a started watcher. Use `/fm-watch-arm-pi` only as a human-entered fallback. Never run `bin/fm-watch-arm.sh` through Pi's bash tool because that foreground arm can wedge the agent and bypasses extension-owned cleanup. 5. The extension starts `bin/fm-watch-arm.sh --restart`, keeps the child attached to the live Pi process, and sends a follow-up user message when the child exits with an actionable watcher reason. @@ -34,10 +36,11 @@ Verification on 2026-07-16 added subprocess-backed resume and lock-owner coverag Command run: `bash tests/fm-session-lock.test.sh`. Observed output included `ok - native Pi comm and argv are classified as a live holder`, `ok - a verified live other holder remains byte-for-byte unchanged`, and `ok - serialized concurrent contenders produce exactly one winner`. Command run: `bash tests/fm-pi-watch-extension.test.sh`. -Observed output included `ok - Pi session_start reclaims missing/dead locks, refuses live other, and idempotently arms owned locks` and `ok - Pi watcher distinguishes verified competitor, acquisition failure, and changed ownership`. +Observed output included `ok - Pi custom tool waits for verified watcher readiness`, `ok - Pi session_start reclaims safe locks, preserves away ownership, refuses live other, and idempotently arms`, `ok - Pi watcher stays inert in linked task worktrees and arms valid secondmate homes`, and `ok - Pi watcher distinguishes verified competitor, acquisition failure, and changed ownership`. -Verification on 2026-07-17 used native Pi 0.80.7 with an isolated saved session, writable copies of the existing Pi authentication and model configuration, `PI_CODING_AGENT_DIR`, `FM_HOME`, project clone, and tmux socket. +Verification on 2026-07-17 used native Pi 0.80.7 with a mode-0700 system temporary lab, an isolated saved session, a deterministic test-owned in-process faux provider, `PI_CODING_AGENT_DIR`, `FM_HOME`, project clone, and tmux socket. Command run: `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh`. +The provider generated every response and tool call locally without reading global Pi authentication or making an API request. The fixture created a saved session with `--session-id`, exited while leaving the dead native Pi PID in `.lock`, and resumed that exact session with `--session`. At the boundary before the real watcher arm script ran, the old PID was dead and `.lock` already named the new native Pi process. Observed output: `ok - Pi 0.80.7 live E2E created, exited, resumed, reclaimed before watcher startup, woke, re-armed, and cleaned up`. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index d80b1ee55..378d1d88f 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -1,7 +1,7 @@ # Primary turn-end supervision guard This is the authoritative contract for the "no turn ends blind" primary guard referenced from AGENTS.md section 8. -The shared predicate lives in `bin/fm-turnend-guard.sh`. +The shared primary scope predicate lives in `bin/fm-primary-scope.sh`, and the turn-end predicate lives in `bin/fm-turnend-guard.sh`. Harness-specific tracked hook files only adapt each verified harness's real turn-end mechanism to that shared predicate. Two related but separate PreToolUse seatbelts deny a bad command shape before it runs rather than detecting a blind turn end afterward: the watcher-arm seatbelt (`bin/fm-arm-pretool-check.sh`, `docs/arm-pretool-check.md`) and the cd-guard (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`). Each seatbelt's own document defines its scope; they do not share the turn-end guard's marker-aware primary detection. @@ -153,7 +153,7 @@ Pi 0.80.7 native resume restores the conversation without rerunning `bin/fm-sess Command run: `bash tests/fm-session-lock.test.sh`. Observed output covered exact native names for Claude, Codex, OpenCode, Pi, and Grok, same-holder idempotence, dead reclaim, malformed refusal, missing ancestry, exact interpreter script matching, and one winner from two synchronized contenders. Command run: `bash tests/fm-pi-watch-extension.test.sh`. -Observed output covered real tracked-extension `session_start` integration for missing, dead, verified live other, and owned locks, plus distinct competitor, acquisition-failure, and ownership-changed messages. +Observed output covered real tracked-extension `session_start` integration for missing, dead, verified live other, owned, and away-mode locks, primary scope for main, linked task, and valid secondmate homes, plus distinct competitor, acquisition-failure, and ownership-changed messages. Command run: `bash tests/fm-turnend-guard.test.sh`. Observed output kept the shared predicate and all five primary harness hook regressions green after removing the Pi guard's duplicate lock classifier. The runtime-backend axis is not applicable because session-lock ownership is per `FM_HOME` and does not call tmux, Herdr, Zellij, Orca, or cmux adapters. diff --git a/tests/fm-pi-primary-live-e2e.test.sh b/tests/fm-pi-primary-live-e2e.test.sh index cdaaff14e..bbaa0d946 100755 --- a/tests/fm-pi-primary-live-e2e.test.sh +++ b/tests/fm-pi-primary-live-e2e.test.sh @@ -14,14 +14,7 @@ command -v tmux >/dev/null 2>&1 || { echo "skip: tmux not found"; exit 0; } TMUX=$(command -v tmux) SOCKET="fm-pi-live-e2e-$$" SESSION=pi-live-e2e -LAB="$ROOT/.pi-live-e2e.$$" -PROJECT="$LAB/project" -HOME_DIR="$LAB/fmhome" -PI_DIR="$LAB/pi-agent" -PI_SESSION_DIR="$PI_DIR/sessions" PI_SESSION_ID=11111111-1111-4111-8111-111111111111 -PI_LAUNCHER="$LAB/run-pi-resume.sh" -PI_CONFIG_SOURCE=${FM_PI_LIVE_E2E_AGENT_DIR:-$HOME/.pi/agent} PI_VERSION=$(pi --version) fail() { @@ -30,9 +23,14 @@ fail() { } [ "$PI_VERSION" = 0.80.7 ] || fail "live resume regression requires Pi 0.80.7, found $PI_VERSION" -for required in auth.json models.json settings.json; do - [ -f "$PI_CONFIG_SOURCE/$required" ] || fail "live resume regression requires $PI_CONFIG_SOURCE/$required" -done +LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-pi-live-e2e.XXXXXX") || fail "could not create the system temporary lab" +chmod 700 "$LAB" || fail "could not restrict the system temporary lab" +umask 077 +PROJECT="$LAB/project" +HOME_DIR="$LAB/fmhome" +PI_DIR="$LAB/pi-agent" +PI_SESSION_DIR="$PI_DIR/sessions" +PI_LAUNCHER="$LAB/run-pi-resume.sh" capture() { "$TMUX" -L "$SOCKET" capture-pane -p -t "$SESSION" -S -600 2>/dev/null || true @@ -130,13 +128,88 @@ wait_pid_dead() { return 1 } -mkdir -p "$LAB" git clone -q "$ROOT" "$PROJECT" cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$PROJECT/.pi/extensions/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$PROJECT/.pi/extensions/fm-primary-turnend-guard.ts" +cp "$ROOT/bin/fm-primary-scope.sh" "$PROJECT/bin/fm-primary-scope.sh" +cp "$ROOT/bin/fm-turnend-guard.sh" "$PROJECT/bin/fm-turnend-guard.sh" cp "$ROOT/bin/fm-supervision-instructions.sh" "$PROJECT/bin/fm-supervision-instructions.sh" +chmod +x "$PROJECT/bin/fm-primary-scope.sh" "$PROJECT/bin/fm-turnend-guard.sh" mkdir -p "$HOME_DIR/state" "$HOME_DIR/config" "$PI_DIR" "$PI_SESSION_DIR" -cp "$PI_CONFIG_SOURCE/auth.json" "$PI_CONFIG_SOURCE/models.json" "$PI_CONFIG_SOURCE/settings.json" "$PI_DIR/" + +cat > "$PROJECT/.pi/extensions/fm-live-e2e-provider.ts" <<'TS' +import { createFauxCore, fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; + +const provider = "fm-live-e2e"; +const model = { + id: provider, + name: "Firstmate live E2E", + reasoning: false, + input: ["text"] as ("text" | "image")[], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32000, + maxTokens: 1024, +}; + +function contentText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((block) => block && typeof block === "object" && (block as { type?: string }).type === "text") + .map((block) => String((block as { text?: string }).text || "")) + .join("\n"); +} + +function respond(context: { messages: Array<{ role: string; content?: unknown; toolName?: string }> }) { + const userIndex = context.messages.findLastIndex((message) => message.role === "user"); + if (userIndex < 0) throw new Error("live E2E provider received no user message"); + const prompt = contentText(context.messages[userIndex].content); + const toolNames = context.messages.slice(userIndex + 1) + .filter((message) => message.role === "toolResult") + .map((message) => message.toolName || ""); + + if (prompt.includes("PI_E2E_BASH_ONE")) { + return toolNames.includes("bash") + ? fauxAssistantMessage("BASH-ONE") + : fauxAssistantMessage(fauxToolCall("bash", { command: "printf PI_E2E_BASH_ONE" }, { id: "bash-one" }), { stopReason: "toolUse" }); + } + if (prompt.includes("first five lines of README.md")) { + return toolNames.includes("read") + ? fauxAssistantMessage("READ-ONE") + : fauxAssistantMessage(fauxToolCall("read", { path: "README.md", offset: 1, limit: 5 }, { id: "read-one" }), { stopReason: "toolUse" }); + } + if (prompt.includes("PI_E2E_BASH_TWO")) { + return toolNames.includes("bash") + ? fauxAssistantMessage("BASH-TWO") + : fauxAssistantMessage(fauxToolCall("bash", { command: "printf PI_E2E_BASH_TWO" }, { id: "bash-two" }), { stopReason: "toolUse" }); + } + if (prompt.includes("Reply exactly READY")) return fauxAssistantMessage("READY"); + if (prompt.startsWith("FIRSTMATE WATCHER WAKE:")) { + if (!toolNames.includes("bash")) { + return fauxAssistantMessage(fauxToolCall("bash", { command: "bin/fm-wake-drain.sh" }, { id: "wake-drain" }), { stopReason: "toolUse" }); + } + if (!toolNames.includes("fm_watch_arm_pi")) { + return fauxAssistantMessage(fauxToolCall("fm_watch_arm_pi", {}, { id: "wake-arm" }), { stopReason: "toolUse" }); + } + return fauxAssistantMessage("REARMED"); + } + throw new Error(`live E2E provider received an unexpected prompt: ${prompt}`); +} + +const faux = createFauxCore({ api: provider, provider, models: [model], tokenSize: { min: 64, max: 64 } }); +faux.setResponses(Array.from({ length: 32 }, () => respond)); + +export default function (pi: { registerProvider(name: string, config: Record): void }) { + pi.registerProvider(provider, { + name: "Firstmate live E2E", + baseUrl: "http://localhost:0", + apiKey: "test-owned-no-network", + api: faux.api, + streamSimple: faux.streamSimple, + models: [model], + }); +} +TS mv "$PROJECT/bin/fm-watch-arm.sh" "$PROJECT/bin/fm-watch-arm-live-e2e-real.sh" cat > "$PROJECT/bin/fm-watch-arm.sh" <<'SH' @@ -165,13 +238,13 @@ cat > "$PI_LAUNCHER" <<'SH' #!/usr/bin/env bash set -u printf '%s\n' "$$" > "$FM_HOME/state/.lock" -pi --session-dir "$PI_SESSION_DIR" --session-id "$PI_SESSION_ID" +pi --provider fm-live-e2e --model fm-live-e2e --session-dir "$PI_SESSION_DIR" --session-id "$PI_SESSION_ID" first_rc=$? printf 'FIRST_PI_EXIT=%s\n' "$first_rc" [ "$first_rc" -eq 0 ] || exit "$first_rc" while [ ! -f "$FM_HOME/state/.resume-now" ]; do sleep 0.1; done printf 'PI_RESUME_STARTING=%s\n' "$PI_SESSION_ID" -pi --session-dir "$PI_SESSION_DIR" --session "$PI_SESSION_ID" +pi --provider fm-live-e2e --model fm-live-e2e --session-dir "$PI_SESSION_DIR" --session "$PI_SESSION_ID" resumed_rc=$? printf 'RESUMED_PI_EXIT=%s\n' "$resumed_rc" [ "$resumed_rc" -eq 0 ] || exit "$resumed_rc" @@ -242,8 +315,8 @@ if printf '%s\n' "$pane" | grep -Fq "$foreground_arm"; then fail "Pi used a foreground bash watcher arm" fi -pid_file=$(find "$HOME_DIR/state" -maxdepth 3 -type f -name pid | head -1) -[ -n "$pid_file" ] || fail "re-armed watcher pid was not recorded" +wait_for_file "$HOME_DIR/state/.watch.lock/pid" 60 || fail "re-armed watcher pid was not recorded" +pid_file="$HOME_DIR/state/.watch.lock/pid" watcher_pid=$(sed -n '1p' "$pid_file") arm_pid=$(ps -p "$watcher_pid" -o ppid= | tr -d ' ') [ -n "$arm_pid" ] || fail "re-armed watcher parent was not live" diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index a8f761868..4c671d7aa 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -15,7 +15,10 @@ export NODE_NO_WARNINGS=1 install_pi_watch_extension_fixture() { local repo=$1 mkdir -p "$repo/.pi/extensions" "$repo/node_modules/typebox" "$repo/bin" + git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || git init -q "$repo" + : > "$repo/AGENTS.md" cp "$EXT" "$repo/.pi/extensions/fm-primary-pi-watch.ts" + cp "$ROOT/bin/fm-primary-scope.sh" "$repo/bin/fm-primary-scope.sh" cat > "$repo/node_modules/typebox/package.json" <<'JSON' {"name":"typebox","type":"module","exports":"./index.js"} JSON @@ -69,7 +72,7 @@ case "$state_name" in esac printf 'lock acquired: harness pid %s\n' "$PPID" SH - chmod +x "$repo/bin/fm-lock.sh" + chmod +x "$repo/bin/fm-lock.sh" "$repo/bin/fm-primary-scope.sh" } install_real_lock_owner() { @@ -93,6 +96,9 @@ test_tracked_extension_present_and_self_hashing() { assert_contains "$text" 'createHash("sha256").update(readFileSync(extensionFile)).digest("hex")' "tracked extension does not self-hash its own content for extensionVersion" assert_contains "$text" 'fileURLToPath(import.meta.url)' "tracked extension does not self-locate via import.meta.url" assert_contains "$text" "const lockScript = \`\${fmRoot}/bin/fm-lock.sh\`" "tracked extension does not resolve the production lock owner" + assert_contains "$text" "const scopeScript = \`\${fmRoot}/bin/fm-primary-scope.sh\`" "tracked extension does not resolve the shared primary scope owner" + assert_contains "$text" 'FM_ROOT_OVERRIDE: root' "tracked extension does not scope the extension checkout independently of inherited supervisor paths" + assert_contains "$text" 'if (!isPrimaryHome())' "tracked extension does not fail closed outside a primary home" assert_contains "$text" 'type LockOwnership = "owned" | "missing" | "dead" | "other" | "malformed" | "unknown"' "tracked extension does not preserve the lock owner states" assert_contains "$text" 'spawnSync(lockScript, ["ownership"]' "tracked extension does not use the lock owner's read-only API" assert_contains "$text" 'if (ownership === "missing" || ownership === "dead")' "tracked extension does not restrict acquisition to reclaimable states" @@ -101,7 +107,9 @@ test_tracked_extension_present_and_self_hashing() { assert_contains "$text" 'lock ownership changed during resume' "tracked extension does not distinguish a lost acquisition race" assert_contains "$text" 'a verified live firstmate session owns this home' "tracked extension does not distinguish a verified competitor" assert_contains "$text" 'lock acquisition failed' "tracked extension does not distinguish acquisition failure" - assert_contains "$text" 'const result = startArm();' "tracked extension does not recover and arm on session_start" + assert_contains "$text" 'const result = await startArm();' "tracked extension does not await recovery and verified arm readiness" + assert_contains "$text" "existsSync(\`\${state}/.afk\`)" "tracked extension does not preserve away-mode supervision ownership" + assert_contains "$text" 'sub-supervisor owns supervision' "tracked extension does not report away-mode ownership" assert_not_contains "$text" 'function parentPid' "tracked extension kept a duplicate ancestry classifier" assert_not_contains "$text" 'function pidAlive' "tracked extension kept a duplicate holder-liveness classifier" assert_not_contains "$text" "readFileSync(\`\${state}/.lock\`" "tracked extension still classifies lock bytes itself" @@ -179,7 +187,7 @@ if (result !== undefined) { console.error(`Pi command returned a value: ${String(result)}`); process.exit(1); } -if (!notification.includes("started Pi extension arm child")) { +if (!notification.includes("external healthy watcher")) { console.error(notification); process.exit(1); } @@ -215,7 +223,7 @@ test_pi_tool_returns_agent_tool_result() { plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash -exit 0 +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" SH chmod +x "$repo/bin/fm-watch-arm.sh" out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" node --input-type=module 2>&1 <<'EOF' @@ -255,6 +263,62 @@ EOF pass "Pi custom tool returns text content and structured details" } +test_pi_tool_waits_for_verified_readiness() { + local repo home plugin started release out status + repo="$TMP_ROOT/pi-tool-readiness-root" + home="$TMP_ROOT/pi-tool-readiness-home" + started="$TMP_ROOT/pi-tool-readiness-started" + release="$TMP_ROOT/pi-tool-readiness-release" + mkdir -p "$repo/bin" "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +: > "${FM_READINESS_STARTED:?}" +while [ ! -f "${FM_READINESS_RELEASE:?}" ]; do sleep 0.01; done +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_READINESS_STARTED="$started" FM_READINESS_RELEASE="$release" node --input-type=module 2>&1 <<'EOF' +import { existsSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +let tool = null; +const pi = { + on() {}, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async () => {}, +}; +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +let resolved = false; +const pending = tool.execute("tool-readiness", {}, undefined, undefined, {}).then((result) => { + resolved = true; + return result; +}); +for (let i = 0; i < 100 && !existsSync(process.env.FM_READINESS_STARTED); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +if (!existsSync(process.env.FM_READINESS_STARTED)) throw new Error("arm child did not reach the readiness boundary"); +await new Promise((resolve) => setTimeout(resolve, 50)); +if (resolved) throw new Error("Pi tool returned before watcher readiness was verified"); +writeFileSync(process.env.FM_READINESS_RELEASE, "ready\n"); +const result = await pending; +if (result.details?.ok !== true || !result.content[0].text.includes("started Pi extension arm child")) { + throw new Error(`unexpected readiness result: ${JSON.stringify(result)}`); +} +EOF +) + status=$? + expect_code 0 "$status" "Pi custom tool must wait for verified watcher readiness" + [ -z "$out" ] || fail "Pi tool-readiness test printed output: $out" + pass "Pi custom tool waits for verified watcher readiness" +} + test_pi_process_exit_cleanup_listener_lifecycle() { local repo home plugin out status repo="$TMP_ROOT/pi-exit-listener-root" @@ -307,6 +371,7 @@ test_pi_process_exit_cleanup_stops_arm_child() { #!/usr/bin/env bash trap 'printf "cleaned\n" > "$FM_CLEANUP_LOG"; exit 0' TERM printf '%s\n' "$$" > "$FM_CHILD_PID_FILE" +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" while :; do sleep 1; done SH chmod +x "$repo/bin/fm-watch-arm.sh" @@ -353,7 +418,7 @@ EOF test_pi_resume_session_recovers_only_reclaimable_locks() { local scenario repo home plugin fakebin arm_log other_pid out status - for scenario in missing dead other owned; do + for scenario in missing dead other owned away; do repo="$TMP_ROOT/pi-resume-$scenario-root" home="$TMP_ROOT/pi-resume-$scenario-home" fakebin="$TMP_ROOT/pi-resume-$scenario-fakebin" @@ -365,6 +430,7 @@ test_pi_resume_session_recovers_only_reclaimable_locks() { cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash printf 'arm\n' >> "${FM_ARM_LOG:?}" +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" trap 'exit 0' TERM INT while :; do sleep 1; done SH @@ -418,6 +484,10 @@ import { pathToFileURL } from "node:url"; process.env.FM_FAKE_PI_PID = String(process.pid); const lock = `${process.env.FM_HOME}/state/.lock`; if (process.env.CASE === "dead") writeFileSync(lock, "999999\n"); +if (process.env.CASE === "away") { + writeFileSync(lock, "999999\n"); + writeFileSync(`${process.env.FM_HOME}/state/.afk`, "away\n"); +} if (process.env.CASE === "other") writeFileSync(lock, `${process.env.FM_FAKE_OTHER_PID}\n`); if (process.env.CASE === "owned") writeFileSync(lock, `${process.pid}\n`); const before = existsSync(lock) ? readFileSync(lock) : null; @@ -447,7 +517,11 @@ await sessionStart({ type: "session_start" }, { }, }); for (let i = 0; i < 100; i += 1) { - const observable = process.env.CASE === "other" ? notifications.length > 0 : existsSync(process.env.FM_ARM_LOG); + const observable = process.env.CASE === "other" + ? notifications.length > 0 + : process.env.CASE === "away" + ? readFileSync(lock, "utf8").trim() === String(process.pid) + : existsSync(process.env.FM_ARM_LOG); if (observable) break; await new Promise((resolve) => setTimeout(resolve, 20)); } @@ -459,6 +533,11 @@ if (process.env.CASE === "other") { if (!notifications.some((message) => message.includes("verified live"))) { throw new Error(`live-other refusal was not explicit: ${notifications.join(" | ")}`); } +} else if (process.env.CASE === "away") { + const acquired = readFileSync(lock, "utf8").trim(); + if (acquired !== String(process.pid)) throw new Error(`away resume lock owner is ${acquired}, expected ${process.pid}`); + if (existsSync(process.env.FM_ARM_LOG)) throw new Error("away resume restarted the watcher"); + if (notifications.length > 0) throw new Error(`away resume notified unexpectedly: ${notifications.join(" | ")}`); } else { const acquired = readFileSync(lock, "utf8").trim(); if (acquired !== String(process.pid)) throw new Error(`resume lock owner is ${acquired}, expected ${process.pid}`); @@ -484,7 +563,77 @@ EOF fi [ -z "$out" ] || fail "Pi resume $scenario integration printed output: $out" done - pass "Pi session_start reclaims missing/dead locks, refuses live other, and idempotently arms owned locks" + pass "Pi session_start reclaims safe locks, preserves away ownership, refuses live other, and idempotently arms" +} + +test_pi_extension_respects_primary_scope() { + local scenario base repo home plugin arm_log out status + for scenario in linked secondmate; do + base="$TMP_ROOT/pi-scope-$scenario-base" + repo="$TMP_ROOT/pi-scope-$scenario-root" + home="$TMP_ROOT/pi-scope-$scenario-home" + arm_log="$TMP_ROOT/pi-scope-$scenario-arm.log" + fm_git_worktree "$base" "$repo" "fm/pi-scope-$scenario" + mkdir -p "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + if [ "$scenario" = secondmate ]; then + printf 'scope-sm\n' > "$repo/.fm-secondmate-home" + fi + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +printf 'arm\n' >> "${FM_ARM_LOG:?}" +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" +trap 'exit 0' TERM INT +while :; do sleep 1; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + status=0 + out=$(CASE="$scenario" PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$arm_log" node --input-type=module 2>&1 <<'EOF' +import { existsSync, readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const notifications = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + registerCommand() {}, + registerTool() {}, + sendUserMessage: async () => {}, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +await handlers.get("session_start")?.({ type: "session_start" }, { + ui: { + notify(message) { + notifications.push(message); + }, + }, +}); +for (let i = 0; i < 100 && process.env.CASE === "secondmate" && !existsSync(process.env.FM_ARM_LOG); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); +} +const lock = `${process.env.FM_HOME}/state/.lock`; +const marker = `${process.env.FM_HOME}/state/.pi-watch-extension-loaded`; +if (process.env.CASE === "linked") { + if (existsSync(lock) || existsSync(marker) || existsSync(process.env.FM_ARM_LOG)) { + throw new Error("linked task worktree mutated inherited supervisor state"); + } + if (notifications.length > 0) throw new Error(`linked task worktree notified unexpectedly: ${notifications.join(" | ")}`); +} else { + if (readFileSync(lock, "utf8").trim() !== String(process.pid)) throw new Error("valid secondmate did not acquire its lock"); + if (!existsSync(marker)) throw new Error("valid secondmate did not mark the extension loaded"); + if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("valid secondmate did not arm supervision"); +} +await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {}); +EOF + ) || status=$? + expect_code 0 "$status" "Pi watcher must enforce primary scope for $scenario" + [ -z "$out" ] || fail "Pi primary-scope $scenario test printed output: $out" + done + pass "Pi watcher stays inert in linked task worktrees and arms valid secondmate homes" } test_pi_extension_distinguishes_lock_refusal_states() { @@ -980,9 +1129,11 @@ test_tracked_extension_present_and_self_hashing test_spawn_template_mentions_pi_watch_placeholder test_pi_extension_reports_external_healthy_watcher test_pi_tool_returns_agent_tool_result +test_pi_tool_waits_for_verified_readiness test_pi_process_exit_cleanup_listener_lifecycle test_pi_process_exit_cleanup_stops_arm_child test_pi_resume_session_recovers_only_reclaimable_locks +test_pi_extension_respects_primary_scope test_pi_extension_distinguishes_lock_refusal_states test_opencode_primary_watch_plugin_static_wiring test_opencode_plugin_package_boundary_is_explicit_esm diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index c7a806e5c..6a5dbab95 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -86,6 +86,7 @@ test_predicate_queue_pending_flag() { install_guard_scripts() { local dir=$1 mkdir -p "$dir/bin" + cp "$ROOT/bin/fm-primary-scope.sh" "$dir/bin/fm-primary-scope.sh" cp "$ROOT/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard.sh" cp "$ROOT/bin/fm-turnend-guard-grok.sh" "$dir/bin/fm-turnend-guard-grok.sh" cp "$ROOT/bin/fm-supervision-instructions.sh" "$dir/bin/fm-supervision-instructions.sh" @@ -94,7 +95,7 @@ install_guard_scripts() { cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh" mkdir -p "$dir/docs" cp -R "$ROOT/docs/supervision-protocols" "$dir/docs/supervision-protocols" - chmod +x "$dir/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard-grok.sh" "$dir/bin/fm-supervision-instructions.sh" "$dir/bin/fm-harness.sh" + chmod +x "$dir/bin/fm-primary-scope.sh" "$dir/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard-grok.sh" "$dir/bin/fm-supervision-instructions.sh" "$dir/bin/fm-harness.sh" } mark_codex_hook_root() { From 44f67537460e7b418020d6fb3bd5877bba19901d Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 17 Jul 2026 11:22:40 +0800 Subject: [PATCH 06/13] no-mistakes(review): Transfer Pi watcher ownership on away entry --- .agents/skills/harness-adapters/SKILL.md | 2 +- .pi/extensions/fm-primary-pi-watch.ts | 35 ++++++++++-- docs/supervision-protocols/pi.md | 3 + tests/fm-pi-watch-extension.test.sh | 72 +++++++++++++++++++++++- 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 4e78b5be2..e0fedba8a 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -216,7 +216,7 @@ The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing. Pi's primary watcher protocol also requires the tracked `.pi/extensions/fm-primary-pi-watch.ts` extension, same trust-once discovery as the turn-end guard. The watcher extension performs the initial `session_start` arm; after a wake, the model re-arms through `fm_watch_arm_pi`, which waits for verified watcher readiness, never a foreground bash arm, with the full recovery and clean-exit protocol owned by `docs/supervision-protocols/pi.md`. -Before lock recovery it requires the shared primary-home scope, and in away mode it may recover the session lock but leaves watcher supervision with the sub-supervisor. +Before lock recovery it requires the shared primary-home scope, and in away mode it may recover the session lock but leaves watcher supervision with the sub-supervisor, including stopping an already-active extension arm when `state/.afk` appears. `bin/fm-session-start.sh` reports when the live Pi session has not loaded both the turn-end guard and watcher extensions, and points at plain `pi` after project trust as the fix, with `-e` as a trust-free fallback. When a secondmate is launched on Pi, `fm-spawn.sh --secondmate` launches Pi with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 499725fdb..d622b1781 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -1,7 +1,7 @@ // Firstmate primary watcher bridge for Pi. import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -24,6 +24,7 @@ const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`; const armScript = `${fmRoot}/bin/fm-watch-arm.sh`; const lockScript = `${fmRoot}/bin/fm-lock.sh`; const scopeScript = `${fmRoot}/bin/fm-primary-scope.sh`; +const awayFlag = `${state}/.afk`; const marker = `${state}/.pi-watch-extension-loaded`; const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; @@ -90,19 +91,41 @@ function failureLine(stdout: string, stderr: string, code: number | null): strin } export default function (pi: ExtensionAPI) { + let awayMonitor: ReturnType | null = null; + const intentionalStops = new WeakSet(); + function stopArm(): void { - if (child) child.kill("SIGTERM"); + if (child) { + intentionalStops.add(child); + child.kill("SIGTERM"); + } child = null; armReady = false; armReadiness = null; } + function reconcileAwayOwnership(): void { + if (existsSync(awayFlag)) stopArm(); + } + + function startAwayMonitor(): void { + if (awayMonitor || !isPrimaryHome()) return; + awayMonitor = watch(state, { persistent: false }, reconcileAwayOwnership); + awayMonitor.on("error", (error) => { + awayMonitor?.close(); + awayMonitor = null; + void sendWake(`watcher: FAILED - Pi away-mode state monitor failed: ${error.message}`); + }); + reconcileAwayOwnership(); + } + const cleanupOnProcessExit = () => { stopArm(); }; process.once("exit", cleanupOnProcessExit); async function sendWake(message: string) { + if (existsSync(awayFlag)) return; await pi.sendUserMessage( `FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first, handle the queued wake, then resume Pi supervision.`, { deliverAs: "followUp" }, @@ -133,7 +156,7 @@ export default function (pi: ExtensionAPI) { return { ok: false, message: `watcher: lock acquisition failed - session lock is ${ownership}${detail}` }; } markLoadedForPrimary(); - if (existsSync(`${state}/.afk`)) { + if (existsSync(awayFlag)) { return { ok: true, message: "watcher: away mode - sub-supervisor owns supervision" }; } if (child) { @@ -175,6 +198,7 @@ export default function (pi: ExtensionAPI) { stderr += chunk.toString(); }); armChild.on("close", async (code: number | null) => { + const intentionallyStopped = intentionalStops.has(armChild); if (child === armChild) { child = null; armReady = false; @@ -186,7 +210,7 @@ export default function (pi: ExtensionAPI) { ok: false, message: failure || `watcher: FAILED - Pi extension arm child ${id} exited before confirming watcher readiness`, }); - if (!reason && !failure) return; + if (intentionallyStopped || (!reason && !failure)) return; try { await sendWake(reason || failure); } catch { @@ -218,6 +242,8 @@ export default function (pi: ExtensionAPI) { if (!result.ok) ctx?.ui?.notify?.(result.message, "warning"); }); pi.on?.("session_shutdown", () => { + awayMonitor?.close(); + awayMonitor = null; stopArm(); process.off("exit", cleanupOnProcessExit); }); @@ -249,4 +275,5 @@ export default function (pi: ExtensionAPI) { }); markLoaded(); + startAwayMonitor(); } diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 919fb82c6..dcc6df2cf 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -7,6 +7,7 @@ When this session owns supervision and away mode is not active: A missing or dead lock is reacquired only through `bin/fm-lock.sh`, ownership is read again, and supervision starts only after the result is `owned`. A verified live other owner remains unchanged and the extension refuses to arm. When `state/.afk` exists, lock recovery still completes but the extension does not start or restart a watcher because the sub-supervisor owns supervision. + If away mode begins after the extension has armed, its state monitor stops that owned arm child and suppresses direct Pi wake delivery during the handoff. 4. After each watcher wake, re-arm supervision with the `fm_watch_arm_pi` tool. The tool returns success only after `fm-watch-arm.sh` verifies and reports a started watcher. Use `/fm-watch-arm-pi` only as a human-entered fallback. @@ -39,6 +40,8 @@ Command run: `bash tests/fm-pi-watch-extension.test.sh`. Observed output included `ok - Pi custom tool waits for verified watcher readiness`, `ok - Pi session_start reclaims safe locks, preserves away ownership, refuses live other, and idempotently arms`, `ok - Pi watcher stays inert in linked task worktrees and arms valid secondmate homes`, and `ok - Pi watcher distinguishes verified competitor, acquisition failure, and changed ownership`. Verification on 2026-07-17 used native Pi 0.80.7 with a mode-0700 system temporary lab, an isolated saved session, a deterministic test-owned in-process faux provider, `PI_CODING_AGENT_DIR`, `FM_HOME`, project clone, and tmux socket. +Command run: `bash tests/fm-pi-watch-extension.test.sh`. +Observed output included `ok - Pi watcher transfers its active arm to away-mode supervision`. Command run: `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh`. The provider generated every response and tool call locally without reading global Pi authentication or making an API request. The fixture created a saved session with `--session-id`, exited while leaving the dead native Pi PID in `.lock`, and resumed that exact session with `--session`. diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 4c671d7aa..2000fcaad 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -108,7 +108,9 @@ test_tracked_extension_present_and_self_hashing() { assert_contains "$text" 'a verified live firstmate session owns this home' "tracked extension does not distinguish a verified competitor" assert_contains "$text" 'lock acquisition failed' "tracked extension does not distinguish acquisition failure" assert_contains "$text" 'const result = await startArm();' "tracked extension does not await recovery and verified arm readiness" - assert_contains "$text" "existsSync(\`\${state}/.afk\`)" "tracked extension does not preserve away-mode supervision ownership" + assert_contains "$text" 'watch(state, { persistent: false }, reconcileAwayOwnership)' "tracked extension does not observe away-mode ownership transitions" + assert_contains "$text" 'if (existsSync(awayFlag)) stopArm();' "tracked extension does not release its active arm on away-mode entry" + assert_contains "$text" 'if (existsSync(awayFlag)) return;' "tracked extension can still route an away-mode wake directly into Pi" assert_contains "$text" 'sub-supervisor owns supervision' "tracked extension does not report away-mode ownership" assert_not_contains "$text" 'function parentPid' "tracked extension kept a duplicate ancestry classifier" assert_not_contains "$text" 'function pidAlive' "tracked extension kept a duplicate holder-liveness classifier" @@ -416,6 +418,73 @@ EOF pass "Pi process-exit cleanup stops the attached arm child" } +test_pi_extension_transfers_active_arm_on_away_entry() { + local repo home plugin arm_log stop_log out status + repo="$TMP_ROOT/pi-away-transfer-root" + home="$TMP_ROOT/pi-away-transfer-home" + arm_log="$TMP_ROOT/pi-away-transfer-arm.log" + stop_log="$TMP_ROOT/pi-away-transfer-stop.log" + mkdir -p "$repo/bin" "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +printf 'arm\n' >> "${FM_ARM_LOG:?}" +trap 'printf "stopped\n" >> "$FM_STOP_LOG"; exit 0' TERM INT +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" +while :; do sleep 1; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$arm_log" FM_STOP_LOG="$stop_log" node --input-type=module 2>&1 <<'EOF' +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +let tool = null; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async (message) => { + prompts.push(message); + }, +}; +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const armed = await tool.execute("away-transfer-arm", {}, undefined, undefined, {}); +if (armed.details?.ok !== true || !existsSync(process.env.FM_ARM_LOG)) { + throw new Error(`initial Pi arm failed: ${JSON.stringify(armed)}`); +} +const pending = `${process.env.FM_HOME}/state/.afk.pending`; +writeFileSync(pending, "away\n"); +renameSync(pending, `${process.env.FM_HOME}/state/.afk`); +for (let i = 0; i < 100 && !existsSync(process.env.FM_STOP_LOG); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); +} +if (!existsSync(process.env.FM_STOP_LOG)) throw new Error("away entry did not stop the extension-owned arm child"); +await new Promise((resolve) => setTimeout(resolve, 50)); +if (prompts.length > 0) throw new Error(`away transfer routed a wake directly into Pi: ${prompts.join(" | ")}`); +const away = await tool.execute("away-transfer-idempotence", {}, undefined, undefined, {}); +if (away.details?.ok !== true || !away.content[0].text.includes("sub-supervisor owns supervision")) { + throw new Error(`away ownership was not preserved: ${JSON.stringify(away)}`); +} +const armCount = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split(/\n/).length; +if (armCount !== 1) throw new Error(`away entry started ${armCount} arm children`); +await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {}); +EOF +) + status=$? + expect_code 0 "$status" "Pi watcher must transfer an active arm to away-mode supervision" + [ -z "$out" ] || fail "Pi away-transfer test printed output: $out" + pass "Pi watcher transfers its active arm to away-mode supervision" +} + test_pi_resume_session_recovers_only_reclaimable_locks() { local scenario repo home plugin fakebin arm_log other_pid out status for scenario in missing dead other owned away; do @@ -1132,6 +1201,7 @@ test_pi_tool_returns_agent_tool_result test_pi_tool_waits_for_verified_readiness test_pi_process_exit_cleanup_listener_lifecycle test_pi_process_exit_cleanup_stops_arm_child +test_pi_extension_transfers_active_arm_on_away_entry test_pi_resume_session_recovers_only_reclaimable_locks test_pi_extension_respects_primary_scope test_pi_extension_distinguishes_lock_refusal_states From c630e40d54069fd4d3e1cc2730ee0d1781dded11 Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 17 Jul 2026 11:38:22 +0800 Subject: [PATCH 07/13] no-mistakes(review): Harden Pi AFK monitor rollback and failure recovery --- .agents/skills/harness-adapters/SKILL.md | 2 +- .pi/extensions/fm-primary-pi-watch.ts | 121 ++++++++++++++++++--- docs/supervision-protocols/pi.md | 6 +- tests/fm-pi-watch-extension.test.sh | 132 +++++++++++++++++++++-- 4 files changed, 233 insertions(+), 28 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index e0fedba8a..40c4131be 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -216,7 +216,7 @@ The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing. Pi's primary watcher protocol also requires the tracked `.pi/extensions/fm-primary-pi-watch.ts` extension, same trust-once discovery as the turn-end guard. The watcher extension performs the initial `session_start` arm; after a wake, the model re-arms through `fm_watch_arm_pi`, which waits for verified watcher readiness, never a foreground bash arm, with the full recovery and clean-exit protocol owned by `docs/supervision-protocols/pi.md`. -Before lock recovery it requires the shared primary-home scope, and in away mode it may recover the session lock but leaves watcher supervision with the sub-supervisor, including stopping an already-active extension arm when `state/.afk` appears. +Before lock recovery it requires the shared primary-home scope and an active away-state monitor; the monitor transfers an active arm to the sub-supervisor when `state/.afk` appears and restores normal supervision if AFK entry rolls back. `bin/fm-session-start.sh` reports when the live Pi session has not loaded both the turn-end guard and watcher extensions, and points at plain `pi` after project trust as the fix, with `-e` as a trust-free fallback. When a secondmate is launched on Pi, `fm-spawn.sh --secondmate` launches Pi with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index d622b1781..b31feef79 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -1,7 +1,7 @@ // Firstmate primary watcher bridge for Pi. import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -91,7 +91,12 @@ function failureLine(stdout: string, stderr: string, code: number | null): strin } export default function (pi: ExtensionAPI) { - let awayMonitor: ReturnType | null = null; + let awayMonitor: ReturnType | null = null; + let awayMonitorError = ""; + let awayMonitorRetry: ReturnType | null = null; + let awayObserved = existsSync(awayFlag); + let sessionActive = false; + let shuttingDown = false; const intentionalStops = new WeakSet(); function stopArm(): void { @@ -104,28 +109,105 @@ export default function (pi: ExtensionAPI) { armReadiness = null; } - function reconcileAwayOwnership(): void { - if (existsSync(awayFlag)) stopArm(); + function readAwayFlag(): boolean { + try { + statSync(awayFlag); + return true; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false; + throw error; + } } - function startAwayMonitor(): void { - if (awayMonitor || !isPrimaryHome()) return; - awayMonitor = watch(state, { persistent: false }, reconcileAwayOwnership); - awayMonitor.on("error", (error) => { - awayMonitor?.close(); - awayMonitor = null; - void sendWake(`watcher: FAILED - Pi away-mode state monitor failed: ${error.message}`); + async function reconcileAwayOwnership(): Promise { + const awayActive = readAwayFlag(); + if (awayActive) { + awayObserved = true; + stopArm(); + return; + } + if (!awayObserved) return; + awayObserved = false; + if (!sessionActive || shuttingDown || !awayMonitor) return; + const result = await startArm(); + if (!result.ok) await sendWake(result.message); + } + + const awayMonitorListener = () => { + void reconcileAwayOwnership().catch((error) => { + handleAwayMonitorFailure(error); }); - reconcileAwayOwnership(); + }; + + function handleAwayMonitorFailure(error: unknown): void { + awayMonitorError = error instanceof Error ? error.message : String(error); + if (awayMonitor) { + clearInterval(awayMonitor); + awayMonitor = null; + } + stopArm(); + scheduleAwayMonitorRetry(); + } + + function scheduleAwayMonitorRetry(): void { + if (awayMonitorRetry || shuttingDown) return; + awayMonitorRetry = setTimeout(() => { + awayMonitorRetry = null; + startAwayMonitor(); + }, 250); + awayMonitorRetry.unref(); + } + + function startAwayMonitor(): boolean { + if (awayMonitor) return true; + if (!isPrimaryHome()) return false; + let awayActive: boolean; + try { + awayActive = readAwayFlag(); + awayMonitor = setInterval(awayMonitorListener, 50); + awayMonitor.unref(); + } catch (error) { + handleAwayMonitorFailure(error); + return false; + } + awayMonitorError = ""; + if (awayMonitorRetry) { + clearTimeout(awayMonitorRetry); + awayMonitorRetry = null; + } + awayObserved = awayActive; + if (awayObserved) stopArm(); + markLoaded(); + if (sessionActive && !awayObserved && !child) { + void startArm().then((result) => { + if (!result.ok) void sendWake(result.message); + }).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + void sendWake(`watcher: FAILED - Pi arm recovery after monitor restart failed: ${message}`); + }); + } + return true; + } + + function stopAwayMonitor(): void { + if (awayMonitor) clearInterval(awayMonitor); + awayMonitor = null; + if (awayMonitorRetry) { + clearTimeout(awayMonitorRetry); + awayMonitorRetry = null; + } } const cleanupOnProcessExit = () => { + shuttingDown = true; + sessionActive = false; + stopAwayMonitor(); stopArm(); }; process.once("exit", cleanupOnProcessExit); async function sendWake(message: string) { - if (existsSync(awayFlag)) return; + if (!awayMonitor || existsSync(awayFlag)) return; await pi.sendUserMessage( `FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first, handle the queued wake, then resume Pi supervision.`, { deliverAs: "followUp" }, @@ -136,6 +218,10 @@ export default function (pi: ExtensionAPI) { if (!isPrimaryHome()) { return { ok: false, message: "watcher: inactive - current checkout is not a primary firstmate home" }; } + if (!awayMonitor) { + const detail = awayMonitorError ? ` (${awayMonitorError})` : ""; + return { ok: false, message: `watcher: inactive - away-mode state monitor unavailable${detail}` }; + } let ownership = lockOwnership(); let acquisitionError = ""; let attemptedAcquisition = false; @@ -238,12 +324,16 @@ export default function (pi: ExtensionAPI) { pi.on?.("session_start", async (_event, ctx) => { if (!isPrimaryHome()) return; + shuttingDown = false; + sessionActive = true; + startAwayMonitor(); const result = await startArm(); if (!result.ok) ctx?.ui?.notify?.(result.message, "warning"); }); pi.on?.("session_shutdown", () => { - awayMonitor?.close(); - awayMonitor = null; + shuttingDown = true; + sessionActive = false; + stopAwayMonitor(); stopArm(); process.off("exit", cleanupOnProcessExit); }); @@ -274,6 +364,5 @@ export default function (pi: ExtensionAPI) { }, }); - markLoaded(); startAwayMonitor(); } diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index dcc6df2cf..cade7f9cb 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -7,7 +7,9 @@ When this session owns supervision and away mode is not active: A missing or dead lock is reacquired only through `bin/fm-lock.sh`, ownership is read again, and supervision starts only after the result is `owned`. A verified live other owner remains unchanged and the extension refuses to arm. When `state/.afk` exists, lock recovery still completes but the extension does not start or restart a watcher because the sub-supervisor owns supervision. - If away mode begins after the extension has armed, its state monitor stops that owned arm child and suppresses direct Pi wake delivery during the handoff. + If away mode begins after the extension has armed, its direct flag monitor stops that owned arm child and suppresses direct Pi wake delivery during the handoff. + If the AFK launch transaction rolls the flag back, the extension restores normal watcher supervision automatically. + The extension publishes its loaded marker and permits arming only after the monitor is active, and retries monitor setup failures without running unsupervised. 4. After each watcher wake, re-arm supervision with the `fm_watch_arm_pi` tool. The tool returns success only after `fm-watch-arm.sh` verifies and reports a started watcher. Use `/fm-watch-arm-pi` only as a human-entered fallback. @@ -41,7 +43,7 @@ Observed output included `ok - Pi custom tool waits for verified watcher readine Verification on 2026-07-17 used native Pi 0.80.7 with a mode-0700 system temporary lab, an isolated saved session, a deterministic test-owned in-process faux provider, `PI_CODING_AGENT_DIR`, `FM_HOME`, project clone, and tmux socket. Command run: `bash tests/fm-pi-watch-extension.test.sh`. -Observed output included `ok - Pi watcher transfers its active arm to away-mode supervision`. +Observed output included `ok - Pi watcher transfers its active arm and restores rollback supervision` and `ok - Pi watcher fails closed and retries away-monitor failures`. Command run: `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh`. The provider generated every response and tool call locally without reading global Pi authentication or making an API request. The fixture created a saved session with `--session-id`, exited while leaving the dead native Pi PID in `.lock`, and resumed that exact session with `--session`. diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 2000fcaad..0539d11a6 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -108,9 +108,11 @@ test_tracked_extension_present_and_self_hashing() { assert_contains "$text" 'a verified live firstmate session owns this home' "tracked extension does not distinguish a verified competitor" assert_contains "$text" 'lock acquisition failed' "tracked extension does not distinguish acquisition failure" assert_contains "$text" 'const result = await startArm();' "tracked extension does not await recovery and verified arm readiness" - assert_contains "$text" 'watch(state, { persistent: false }, reconcileAwayOwnership)' "tracked extension does not observe away-mode ownership transitions" - assert_contains "$text" 'if (existsSync(awayFlag)) stopArm();' "tracked extension does not release its active arm on away-mode entry" - assert_contains "$text" 'if (existsSync(awayFlag)) return;' "tracked extension can still route an away-mode wake directly into Pi" + assert_contains "$text" 'awayMonitor = setInterval(awayMonitorListener, 50)' "tracked extension does not poll the away flag directly" + assert_contains "$text" 'handleAwayMonitorFailure(error);' "tracked extension does not fail closed on away-monitor errors" + assert_contains "$text" 'scheduleAwayMonitorRetry();' "tracked extension does not retry away-monitor setup failures" + assert_contains "$text" 'if (!awayMonitor)' "tracked extension can arm before away-mode monitoring is active" + assert_contains "$text" 'if (!awayMonitor || existsSync(awayFlag)) return;' "tracked extension can still route a wake without confirmed normal-mode ownership" assert_contains "$text" 'sub-supervisor owns supervision' "tracked extension does not report away-mode ownership" assert_not_contains "$text" 'function parentPid' "tracked extension kept a duplicate ancestry classifier" assert_not_contains "$text" 'function pidAlive' "tracked extension kept a duplicate holder-liveness classifier" @@ -436,7 +438,7 @@ while :; do sleep 1; done SH chmod +x "$repo/bin/fm-watch-arm.sh" out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$arm_log" FM_STOP_LOG="$stop_log" node --input-type=module 2>&1 <<'EOF' -import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const handlers = new Map(); @@ -457,10 +459,8 @@ const pi = { writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); -const armed = await tool.execute("away-transfer-arm", {}, undefined, undefined, {}); -if (armed.details?.ok !== true || !existsSync(process.env.FM_ARM_LOG)) { - throw new Error(`initial Pi arm failed: ${JSON.stringify(armed)}`); -} +await handlers.get("session_start")?.({ type: "session_start" }, { ui: { notify() {} } }); +if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("initial Pi session did not arm supervision"); const pending = `${process.env.FM_HOME}/state/.afk.pending`; writeFileSync(pending, "away\n"); renameSync(pending, `${process.env.FM_HOME}/state/.afk`); @@ -476,13 +476,126 @@ if (away.details?.ok !== true || !away.content[0].text.includes("sub-supervisor } const armCount = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split(/\n/).length; if (armCount !== 1) throw new Error(`away entry started ${armCount} arm children`); +unlinkSync(`${process.env.FM_HOME}/state/.afk`); +for (let i = 0; i < 100; i += 1) { + const count = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split(/\n/).length; + if (count === 2) break; + await new Promise((resolve) => setTimeout(resolve, 20)); +} +const restoredCount = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split(/\n/).length; +if (restoredCount !== 2) throw new Error(`away rollback left ${restoredCount} arm children instead of restoring one`); +if (prompts.length > 0) throw new Error(`away rollback emitted an unexpected Pi wake: ${prompts.join(" | ")}`); await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {}); EOF ) status=$? expect_code 0 "$status" "Pi watcher must transfer an active arm to away-mode supervision" [ -z "$out" ] || fail "Pi away-transfer test printed output: $out" - pass "Pi watcher transfers its active arm to away-mode supervision" + pass "Pi watcher transfers its active arm and restores rollback supervision" +} + +test_pi_extension_fails_closed_and_retries_away_monitor() { + local scenario repo home plugin arm_log stop_log fail_flag out status + for scenario in failure retry runtime; do + repo="$TMP_ROOT/pi-away-monitor-$scenario-root" + home="$TMP_ROOT/pi-away-monitor-$scenario-home" + arm_log="$TMP_ROOT/pi-away-monitor-$scenario-arm.log" + stop_log="$TMP_ROOT/pi-away-monitor-$scenario-stop.log" + fail_flag="$TMP_ROOT/pi-away-monitor-$scenario-fail" + mkdir -p "$repo/bin" "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +printf 'arm\n' >> "${FM_ARM_LOG:?}" +trap 'printf "stopped\n" >> "$FM_STOP_LOG"; exit 0' TERM INT +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" +while :; do sleep 1; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(CASE="$scenario" PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$arm_log" FM_STOP_LOG="$stop_log" FM_MONITOR_FAIL="$fail_flag" node --input-type=module 2>&1 <<'EOF' +import fs, { existsSync, writeFileSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { pathToFileURL } from "node:url"; + +const originalStatSync = fs.statSync; +let attempts = 0; +fs.statSync = (...args) => { + attempts += 1; + if (process.env.CASE === "failure" || (process.env.CASE === "retry" && attempts === 1)) { + throw Object.assign(new Error("fixture monitor failure"), { code: "EIO" }); + } + if (process.env.CASE === "runtime" && existsSync(process.env.FM_MONITOR_FAIL)) { + throw Object.assign(new Error("fixture runtime monitor failure"), { code: "EIO" }); + } + return originalStatSync(...args); +}; +syncBuiltinESMExports(); + +const handlers = new Map(); +const notifications = []; +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + registerCommand() {}, + registerTool() {}, + sendUserMessage: async (message) => { + prompts.push(message); + }, +}; +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const marker = `${process.env.FM_HOME}/state/.pi-watch-extension-loaded`; +if (process.env.CASE !== "runtime" && existsSync(marker)) { + throw new Error("extension advertised loaded before away monitoring was active"); +} +if (process.env.CASE === "runtime" && !existsSync(marker)) { + throw new Error("runtime monitor fixture did not start healthy"); +} +if (process.env.CASE === "retry") { + for (let i = 0; i < 100 && !existsSync(marker); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + if (!existsSync(marker)) throw new Error("away monitor setup was not retried"); +} +await handlers.get("session_start")?.({ type: "session_start" }, { + ui: { + notify(message) { + notifications.push(message); + }, + }, +}); +if (process.env.CASE === "failure") { + if (existsSync(marker) || existsSync(process.env.FM_ARM_LOG)) { + throw new Error("monitor failure advertised or armed the Pi watcher"); + } + if (!notifications.some((message) => message.includes("away-mode state monitor unavailable"))) { + throw new Error(`monitor failure was not reported: ${notifications.join(" | ")}`); + } +} else if (process.env.CASE === "retry") { + if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("recovered away monitor did not permit watcher arming"); + if (notifications.length > 0) throw new Error(`monitor retry notified unexpectedly: ${notifications.join(" | ")}`); +} else { + if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("runtime monitor fixture did not arm supervision"); + writeFileSync(process.env.FM_MONITOR_FAIL, "fail\n"); + writeFileSync(`${process.env.FM_HOME}/state/.afk`, "away\n"); + for (let i = 0; i < 100 && !existsSync(process.env.FM_STOP_LOG); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + if (!existsSync(process.env.FM_STOP_LOG)) throw new Error("runtime monitor failure did not stop the active arm"); + if (prompts.length > 0) throw new Error(`runtime monitor failure routed a wake into Pi: ${prompts.join(" | ")}`); +} +await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {}); +EOF + ) + status=$? + expect_code 0 "$status" "Pi watcher must handle away-monitor $scenario fail closed" + [ -z "$out" ] || fail "Pi away-monitor $scenario test printed output: $out" + done + pass "Pi watcher fails closed and retries away-monitor failures" } test_pi_resume_session_recovers_only_reclaimable_locks() { @@ -1202,6 +1315,7 @@ test_pi_tool_waits_for_verified_readiness test_pi_process_exit_cleanup_listener_lifecycle test_pi_process_exit_cleanup_stops_arm_child test_pi_extension_transfers_active_arm_on_away_entry +test_pi_extension_fails_closed_and_retries_away_monitor test_pi_resume_session_recovers_only_reclaimable_locks test_pi_extension_respects_primary_scope test_pi_extension_distinguishes_lock_refusal_states From b0b7437640d4fb705e602c58e6441ca19d69a4e7 Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 17 Jul 2026 11:50:25 +0800 Subject: [PATCH 08/13] no-mistakes(review): Contain Pi wake rejection and back off monitor retries --- .pi/extensions/fm-primary-pi-watch.ts | 60 ++++++++++++++----------- docs/supervision-protocols/pi.md | 5 ++- tests/fm-pi-watch-extension.test.sh | 65 ++++++++++++++++++++++----- 3 files changed, 90 insertions(+), 40 deletions(-) diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index b31feef79..44c857eec 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -70,11 +70,6 @@ function markLoadedForPrimary(): void { writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); } -function markLoaded(): void { - if (!isPrimaryHome()) return; - markLoadedForPrimary(); -} - function actionableLine(output: string): string { const lines = output.split(/\r?\n/); return lines.find((line) => /^(signal:|stale:|check:|heartbeat($|:))/.test(line)) || ""; @@ -91,9 +86,11 @@ function failureLine(stdout: string, stderr: string, code: number | null): strin } export default function (pi: ExtensionAPI) { + const primaryHome = isPrimaryHome(); let awayMonitor: ReturnType | null = null; let awayMonitorError = ""; let awayMonitorRetry: ReturnType | null = null; + let awayMonitorRetryDelayMs = 250; let awayObserved = existsSync(awayFlag); let sessionActive = false; let shuttingDown = false; @@ -130,7 +127,7 @@ export default function (pi: ExtensionAPI) { awayObserved = false; if (!sessionActive || shuttingDown || !awayMonitor) return; const result = await startArm(); - if (!result.ok) await sendWake(result.message); + if (!result.ok) await sendWakeSafely(result.message); } const awayMonitorListener = () => { @@ -151,16 +148,18 @@ export default function (pi: ExtensionAPI) { function scheduleAwayMonitorRetry(): void { if (awayMonitorRetry || shuttingDown) return; + const delay = awayMonitorRetryDelayMs; + awayMonitorRetryDelayMs = Math.min(awayMonitorRetryDelayMs * 2, 30000); awayMonitorRetry = setTimeout(() => { awayMonitorRetry = null; startAwayMonitor(); - }, 250); + }, delay); awayMonitorRetry.unref(); } function startAwayMonitor(): boolean { if (awayMonitor) return true; - if (!isPrimaryHome()) return false; + if (!primaryHome) return false; let awayActive: boolean; try { awayActive = readAwayFlag(); @@ -171,20 +170,16 @@ export default function (pi: ExtensionAPI) { return false; } awayMonitorError = ""; + awayMonitorRetryDelayMs = 250; if (awayMonitorRetry) { clearTimeout(awayMonitorRetry); awayMonitorRetry = null; } awayObserved = awayActive; if (awayObserved) stopArm(); - markLoaded(); + markLoadedForPrimary(); if (sessionActive && !awayObserved && !child) { - void startArm().then((result) => { - if (!result.ok) void sendWake(result.message); - }).catch((error) => { - const message = error instanceof Error ? error.message : String(error); - void sendWake(`watcher: FAILED - Pi arm recovery after monitor restart failed: ${message}`); - }); + void recoverArmAfterMonitor(); } return true; } @@ -196,6 +191,7 @@ export default function (pi: ExtensionAPI) { clearTimeout(awayMonitorRetry); awayMonitorRetry = null; } + awayMonitorRetryDelayMs = 250; } const cleanupOnProcessExit = () => { @@ -214,8 +210,26 @@ export default function (pi: ExtensionAPI) { ); } + async function sendWakeSafely(message: string): Promise { + try { + await sendWake(message); + } catch { + return; + } + } + + async function recoverArmAfterMonitor(): Promise { + try { + const result = await startArm(); + if (!result.ok) await sendWakeSafely(result.message); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await sendWakeSafely(`watcher: FAILED - Pi arm recovery after monitor restart failed: ${message}`); + } + } + async function startArm(): Promise { - if (!isPrimaryHome()) { + if (!primaryHome) { return { ok: false, message: "watcher: inactive - current checkout is not a primary firstmate home" }; } if (!awayMonitor) { @@ -297,11 +311,7 @@ export default function (pi: ExtensionAPI) { message: failure || `watcher: FAILED - Pi extension arm child ${id} exited before confirming watcher readiness`, }); if (intentionallyStopped || (!reason && !failure)) return; - try { - await sendWake(reason || failure); - } catch { - // Pi owns delivery errors; fail open so the extension never wedges the session. - } + await sendWakeSafely(reason || failure); }); armChild.on("error", async (error: Error) => { if (child === armChild) { @@ -311,11 +321,7 @@ export default function (pi: ExtensionAPI) { } const failure = `watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`; settle({ ok: false, message: failure }); - try { - await sendWake(failure); - } catch { - // Fail open. - } + await sendWakeSafely(failure); }); }); armReadiness = readiness; @@ -323,7 +329,7 @@ export default function (pi: ExtensionAPI) { } pi.on?.("session_start", async (_event, ctx) => { - if (!isPrimaryHome()) return; + if (!primaryHome) return; shuttingDown = false; sessionActive = true; startAwayMonitor(); diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index cade7f9cb..f48aaa406 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -9,7 +9,8 @@ When this session owns supervision and away mode is not active: When `state/.afk` exists, lock recovery still completes but the extension does not start or restart a watcher because the sub-supervisor owns supervision. If away mode begins after the extension has armed, its direct flag monitor stops that owned arm child and suppresses direct Pi wake delivery during the handoff. If the AFK launch transaction rolls the flag back, the extension restores normal watcher supervision automatically. - The extension publishes its loaded marker and permits arming only after the monitor is active, and retries monitor setup failures without running unsupervised. + The extension publishes its loaded marker and permits arming only after the monitor is active, and retries monitor failures with capped exponential backoff without re-running invariant primary-scope probes. + Recovery wake delivery is rejection-safe and cannot escape as an unhandled Pi lifecycle promise. 4. After each watcher wake, re-arm supervision with the `fm_watch_arm_pi` tool. The tool returns success only after `fm-watch-arm.sh` verifies and reports a started watcher. Use `/fm-watch-arm-pi` only as a human-entered fallback. @@ -43,7 +44,7 @@ Observed output included `ok - Pi custom tool waits for verified watcher readine Verification on 2026-07-17 used native Pi 0.80.7 with a mode-0700 system temporary lab, an isolated saved session, a deterministic test-owned in-process faux provider, `PI_CODING_AGENT_DIR`, `FM_HOME`, project clone, and tmux socket. Command run: `bash tests/fm-pi-watch-extension.test.sh`. -Observed output included `ok - Pi watcher transfers its active arm and restores rollback supervision` and `ok - Pi watcher fails closed and retries away-monitor failures`. +Observed output included `ok - Pi watcher transfers its active arm and restores rollback supervision` and `ok - Pi watcher contains delivery rejection and backs off monitor failures`. Command run: `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh`. The provider generated every response and tool call locally without reading global Pi authentication or making an API request. The fixture created a saved session with `--session-id`, exited while leaving the dead native Pi PID in `.lock`, and resumed that exact session with `--session`. diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 0539d11a6..a8fe3f50a 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -98,7 +98,7 @@ test_tracked_extension_present_and_self_hashing() { assert_contains "$text" "const lockScript = \`\${fmRoot}/bin/fm-lock.sh\`" "tracked extension does not resolve the production lock owner" assert_contains "$text" "const scopeScript = \`\${fmRoot}/bin/fm-primary-scope.sh\`" "tracked extension does not resolve the shared primary scope owner" assert_contains "$text" 'FM_ROOT_OVERRIDE: root' "tracked extension does not scope the extension checkout independently of inherited supervisor paths" - assert_contains "$text" 'if (!isPrimaryHome())' "tracked extension does not fail closed outside a primary home" + assert_contains "$text" 'if (!primaryHome)' "tracked extension does not fail closed outside a primary home" assert_contains "$text" 'type LockOwnership = "owned" | "missing" | "dead" | "other" | "malformed" | "unknown"' "tracked extension does not preserve the lock owner states" assert_contains "$text" 'spawnSync(lockScript, ["ownership"]' "tracked extension does not use the lock owner's read-only API" assert_contains "$text" 'if (ownership === "missing" || ownership === "dead")' "tracked extension does not restrict acquisition to reclaimable states" @@ -111,8 +111,11 @@ test_tracked_extension_present_and_self_hashing() { assert_contains "$text" 'awayMonitor = setInterval(awayMonitorListener, 50)' "tracked extension does not poll the away flag directly" assert_contains "$text" 'handleAwayMonitorFailure(error);' "tracked extension does not fail closed on away-monitor errors" assert_contains "$text" 'scheduleAwayMonitorRetry();' "tracked extension does not retry away-monitor setup failures" + assert_contains "$text" 'awayMonitorRetryDelayMs = Math.min(awayMonitorRetryDelayMs * 2, 30000)' "tracked extension does not cap exponential monitor retry backoff" + assert_contains "$text" 'const primaryHome = isPrimaryHome();' "tracked extension does not cache invariant primary scope" assert_contains "$text" 'if (!awayMonitor)' "tracked extension can arm before away-mode monitoring is active" assert_contains "$text" 'if (!awayMonitor || existsSync(awayFlag)) return;' "tracked extension can still route a wake without confirmed normal-mode ownership" + assert_contains "$text" 'await sendWakeSafely' "tracked extension does not contain rejected Pi wake delivery" assert_contains "$text" 'sub-supervisor owns supervision' "tracked extension does not report away-mode ownership" assert_not_contains "$text" 'function parentPid' "tracked extension kept a duplicate ancestry classifier" assert_not_contains "$text" 'function pidAlive' "tracked extension kept a duplicate holder-liveness classifier" @@ -495,26 +498,38 @@ EOF } test_pi_extension_fails_closed_and_retries_away_monitor() { - local scenario repo home plugin arm_log stop_log fail_flag out status - for scenario in failure retry runtime; do + local scenario repo home plugin arm_log stop_log fail_flag scope_log attempts_log fail_second out status + for scenario in failure retry runtime delivery; do repo="$TMP_ROOT/pi-away-monitor-$scenario-root" home="$TMP_ROOT/pi-away-monitor-$scenario-home" arm_log="$TMP_ROOT/pi-away-monitor-$scenario-arm.log" stop_log="$TMP_ROOT/pi-away-monitor-$scenario-stop.log" fail_flag="$TMP_ROOT/pi-away-monitor-$scenario-fail" + scope_log="$TMP_ROOT/pi-away-monitor-$scenario-scope.log" + attempts_log="$TMP_ROOT/pi-away-monitor-$scenario-attempts.log" + fail_second=0 + [ "$scenario" = delivery ] && fail_second=1 mkdir -p "$repo/bin" "$home/state" "$home/config" install_pi_watch_extension_fixture "$repo" plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + mv "$repo/bin/fm-primary-scope.sh" "$repo/bin/fm-primary-scope-real.sh" + cat > "$repo/bin/fm-primary-scope.sh" <<'SH' +#!/usr/bin/env bash +printf 'scope\n' >> "${FM_SCOPE_LOG:?}" +exec "$(dirname "$0")/fm-primary-scope-real.sh" +SH cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash printf 'arm\n' >> "${FM_ARM_LOG:?}" +count=$(wc -l < "$FM_ARM_LOG" | tr -d ' ') +if [ "${FM_FAIL_SECOND_ARM:-0}" = 1 ] && [ "$count" -gt 1 ]; then exit 9; fi trap 'printf "stopped\n" >> "$FM_STOP_LOG"; exit 0' TERM INT printf 'watcher: started pid=%s (beacon fresh)\n' "$$" while :; do sleep 1; done SH - chmod +x "$repo/bin/fm-watch-arm.sh" - out=$(CASE="$scenario" PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$arm_log" FM_STOP_LOG="$stop_log" FM_MONITOR_FAIL="$fail_flag" node --input-type=module 2>&1 <<'EOF' -import fs, { existsSync, writeFileSync } from "node:fs"; + chmod +x "$repo/bin/fm-primary-scope.sh" "$repo/bin/fm-watch-arm.sh" + out=$(CASE="$scenario" PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$arm_log" FM_STOP_LOG="$stop_log" FM_MONITOR_FAIL="$fail_flag" FM_SCOPE_LOG="$scope_log" FM_MONITOR_ATTEMPTS="$attempts_log" FM_FAIL_SECOND_ARM="$fail_second" node --input-type=module 2>&1 <<'EOF' +import fs, { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { syncBuiltinESMExports } from "node:module"; import { pathToFileURL } from "node:url"; @@ -522,10 +537,11 @@ const originalStatSync = fs.statSync; let attempts = 0; fs.statSync = (...args) => { attempts += 1; + writeFileSync(process.env.FM_MONITOR_ATTEMPTS, `${Date.now()}\n`, { flag: "a" }); if (process.env.CASE === "failure" || (process.env.CASE === "retry" && attempts === 1)) { throw Object.assign(new Error("fixture monitor failure"), { code: "EIO" }); } - if (process.env.CASE === "runtime" && existsSync(process.env.FM_MONITOR_FAIL)) { + if (["runtime", "delivery"].includes(process.env.CASE) && existsSync(process.env.FM_MONITOR_FAIL)) { throw Object.assign(new Error("fixture runtime monitor failure"), { code: "EIO" }); } return originalStatSync(...args); @@ -535,6 +551,10 @@ syncBuiltinESMExports(); const handlers = new Map(); const notifications = []; const prompts = []; +const unhandled = []; +process.on("unhandledRejection", (error) => { + unhandled.push(error); +}); const pi = { on(event, handler) { handlers.set(event, handler); @@ -542,6 +562,7 @@ const pi = { registerCommand() {}, registerTool() {}, sendUserMessage: async (message) => { + if (process.env.CASE === "delivery") throw new Error("fixture delivery rejection"); prompts.push(message); }, }; @@ -549,10 +570,10 @@ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); const marker = `${process.env.FM_HOME}/state/.pi-watch-extension-loaded`; -if (process.env.CASE !== "runtime" && existsSync(marker)) { +if (!["runtime", "delivery"].includes(process.env.CASE) && existsSync(marker)) { throw new Error("extension advertised loaded before away monitoring was active"); } -if (process.env.CASE === "runtime" && !existsSync(marker)) { +if (["runtime", "delivery"].includes(process.env.CASE) && !existsSync(marker)) { throw new Error("runtime monitor fixture did not start healthy"); } if (process.env.CASE === "retry") { @@ -569,16 +590,19 @@ await handlers.get("session_start")?.({ type: "session_start" }, { }, }); if (process.env.CASE === "failure") { + await new Promise((resolve) => setTimeout(resolve, 900)); if (existsSync(marker) || existsSync(process.env.FM_ARM_LOG)) { throw new Error("monitor failure advertised or armed the Pi watcher"); } if (!notifications.some((message) => message.includes("away-mode state monitor unavailable"))) { throw new Error(`monitor failure was not reported: ${notifications.join(" | ")}`); } + const monitorAttempts = readFileSync(process.env.FM_MONITOR_ATTEMPTS, "utf8").trim().split(/\n/).length; + if (monitorAttempts > 4) throw new Error(`persistent monitor failure retried ${monitorAttempts} times without backoff`); } else if (process.env.CASE === "retry") { if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("recovered away monitor did not permit watcher arming"); if (notifications.length > 0) throw new Error(`monitor retry notified unexpectedly: ${notifications.join(" | ")}`); -} else { +} else if (process.env.CASE === "runtime") { if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("runtime monitor fixture did not arm supervision"); writeFileSync(process.env.FM_MONITOR_FAIL, "fail\n"); writeFileSync(`${process.env.FM_HOME}/state/.afk`, "away\n"); @@ -587,7 +611,26 @@ if (process.env.CASE === "failure") { } if (!existsSync(process.env.FM_STOP_LOG)) throw new Error("runtime monitor failure did not stop the active arm"); if (prompts.length > 0) throw new Error(`runtime monitor failure routed a wake into Pi: ${prompts.join(" | ")}`); +} else { + if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("delivery fixture did not arm supervision"); + writeFileSync(process.env.FM_MONITOR_FAIL, "fail\n"); + for (let i = 0; i < 100 && !existsSync(process.env.FM_STOP_LOG); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + if (!existsSync(process.env.FM_STOP_LOG)) throw new Error("delivery fixture did not enter monitor recovery"); + unlinkSync(process.env.FM_MONITOR_FAIL); + for (let i = 0; i < 100; i += 1) { + const count = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split(/\n/).length; + if (count === 2) break; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + const deliveryArmCount = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split(/\n/).length; + if (deliveryArmCount !== 2) throw new Error(`delivery recovery made ${deliveryArmCount} arm attempts`); + await new Promise((resolve) => setTimeout(resolve, 100)); + if (unhandled.length > 0) throw new Error(`monitor recovery leaked an unhandled rejection: ${unhandled.join(" | ")}`); } +const scopeCount = readFileSync(process.env.FM_SCOPE_LOG, "utf8").trim().split(/\n/).length; +if (scopeCount !== 1) throw new Error(`away-monitor recovery rechecked primary scope ${scopeCount} times`); await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {}); EOF ) @@ -595,7 +638,7 @@ EOF expect_code 0 "$status" "Pi watcher must handle away-monitor $scenario fail closed" [ -z "$out" ] || fail "Pi away-monitor $scenario test printed output: $out" done - pass "Pi watcher fails closed and retries away-monitor failures" + pass "Pi watcher contains delivery rejection and backs off monitor failures" } test_pi_resume_session_recovers_only_reclaimable_locks() { From 96c58c6c473e230c83e25db3bf6d059d0c397927 Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 17 Jul 2026 12:44:03 +0800 Subject: [PATCH 09/13] no-mistakes(test): Pin tmux smoke test to Bash --- tests/fm-backend-tmux-smoke.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/fm-backend-tmux-smoke.test.sh b/tests/fm-backend-tmux-smoke.test.sh index 0d47b83ec..b738af605 100755 --- a/tests/fm-backend-tmux-smoke.test.sh +++ b/tests/fm-backend-tmux-smoke.test.sh @@ -48,6 +48,8 @@ TARGET="$SESSION:$WINDOW" tmux new-session -d -s "$SESSION" -x 200 -y 50 \ || fail "real tmux: new-session failed" +tmux set-option -g default-command "$BASH --noprofile --norc" \ + || fail "real tmux: failed to pin the smoke-test shell" fm_backend_tmux_create_task "$SESSION" "$WINDOW" "$HOME" \ || fail "fm_backend_tmux_create_task failed to create the task window" tmux list-windows -t "$SESSION" -F '#{window_name}' | grep -qx "$WINDOW" \ From 832616a1b13db6f84733d0fbe90431660f3de9d1 Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 17 Jul 2026 13:47:10 +0800 Subject: [PATCH 10/13] no-mistakes(test): Fix locale-dependent composer classification --- bin/backends/herdr.sh | 4 ++-- bin/fm-composer-lib.sh | 13 +++++++++---- docs/configuration.md | 2 +- tests/fm-backend-cmux.test.sh | 2 +- tests/fm-backend-herdr.test.sh | 2 +- tests/fm-composer-lib.test.sh | 3 ++- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 260d2b666..29de3ddb5 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -739,7 +739,7 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'} # Known bare (unbordered) prompt glyphs a composer row may start with: ❯ # (claude) and › (codex) only. Generic shell-style glyphs > $ % # are still # recognized after a bordered composer row has already been structurally found. -FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} +FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^(❯|›)'} # Pi allows a multi-line composer between its horizontal separators. Bound the # structural candidate so two unrelated transcript rules with an arbitrarily # large region between them can never be promoted into a composer. @@ -903,7 +903,7 @@ EOF fi # Delegate the empty/pending/unknown decision to the shared owner. The bare # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE - # is '^[❯›]'), so a bare shell prompt never reaches here - it stays 'unknown' + # is '^(❯|›)'), so a bare shell prompt never reaches here - it stays 'unknown' # via the no-composer-row path above, exactly as before. fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE" } diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 437b8c689..784319b9b 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -180,7 +180,7 @@ fm_composer_idle_matches() { } fm_composer_classify_content() { # [idle_re] [idle_case] [plain_content] - local bordered=$1 content=$2 idle_re=${3:-} idle_case=${4:-sensitive} plain_content + local bordered=$1 content=$2 idle_re=${3:-} idle_case=${4:-sensitive} plain_content prompt="" plain_content=${5:-$content} if [ "$bordered" != 1 ] && [ -z "$content" ] && [ -n "$plain_content" ]; then case "$plain_content" in @@ -205,11 +205,16 @@ fm_composer_classify_content() { # [idle_re] [idle_case] [ if fm_composer_idle_matches "$content" "$idle_re" "$idle_case"; then printf 'empty'; return 0 fi - # Strip a leading prompt glyph, then re-judge the remainder. + # Strip the literal prompt bytes so the verdict is independent of locale. case "$content" in - '❯ '*|'› '*|'> '*|'$ '*|'% '*|'# '*) content=${content#??} ;; - '❯'*|'›'*|'>'*|'$'*|'%'*|'#'*) content=${content#?} ;; + '❯'*) prompt='❯' ;; + '›'*) prompt='›' ;; + '>'*) prompt='>' ;; + '$'*) prompt='$' ;; + '%'*) prompt='%' ;; + '#'*) prompt='#' ;; esac + [ -z "$prompt" ] || content=${content#"$prompt"} content="${content#"${content%%[![:space:]]*}"}" content="${content%"${content##*[![:space:]]}"}" [ -n "$content" ] || { printf 'empty'; return 0; } diff --git a/docs/configuration.md b/docs/configuration.md index 6920d919c..6dd3b9102 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -348,7 +348,7 @@ FM_BACKEND= # optional runtime backend override for new spawns; tmux HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping -FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text (dim or dark-truecolor) after an agent prompt reads empty via the shared fm_composer_strip_ghost (docs/herdr-backend.md "Incident (2026-07-08)", "Incident (2026-07-10)") +FM_BACKEND_HERDR_BARE_PROMPT_RE='^(❯|›)' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text (dim or dark-truecolor) after an agent prompt reads empty via the shared fm_composer_strip_ghost (docs/herdr-backend.md "Incident (2026-07-08)", "Incident (2026-07-10)") FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=8 # herdr-only: maximum rows admitted between Pi's native-identity-corroborated separator pair; taller or ambiguous candidates stay unknown (docs/herdr-backend.md "Incident (2026-07-14)") FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Native agent-state submit confirmation") FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline diff --git a/tests/fm-backend-cmux.test.sh b/tests/fm-backend-cmux.test.sh index 79c16b13a..f83eec4ab 100755 --- a/tests/fm-backend-cmux.test.sh +++ b/tests/fm-backend-cmux.test.sh @@ -691,7 +691,7 @@ test_composer_state_ghost_placeholder_is_empty() { cmux_panes_response "$dir" 1 "bbbbbbbb-1111-1111-1111-111111111111" cmux_read_screen_response "$dir" 2 $' ╭────────────────────────╮\n │ ❯ Type a message... │\n ╰──────── Composer ─────╯' fb=$(make_cmux_fakebin "$dir") - out=$( PATH="$fb:$PATH" FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ + out=$( LC_ALL=C LANG=C PATH="$fb:$PATH" FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ bash -c '. "$0/bin/backends/cmux.sh"; fm_backend_cmux_composer_state "aaaaaaaa-0000-0000-0000-000000000000:bbbbbbbb-1111-1111-1111-111111111111"' "$ROOT" ) [ "$out" = empty ] || fail "the known ghost placeholder 'Type a message...' should read as empty, got '$out'" pass "fm_backend_cmux_composer_state: the ghost placeholder text reads empty, not pending" diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 5186cca2c..bcf33f3c4 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -817,7 +817,7 @@ test_composer_state_bare_prompt_is_empty() { dir="$TMP_ROOT/composer-bare"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" printf ' ╭────────────────────────╮\n │ ❯ │\n ╰──────── Composer ─────╯\n\n Shift+Tab:mode\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") - out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + out=$( LC_ALL=C LANG=C PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = empty ] || fail "a bare prompt glyph should read as empty, got '$out'" pass "fm_backend_herdr_composer_state: a bare '❯' composer row reads empty" diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index 53cb83114..1ded45dfe 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -97,7 +97,8 @@ test_idle_placeholder_is_empty() { out=$(classify 1 'Type a message...' "$idle") [ "$out" = empty ] || fail "the grok idle placeholder should read empty, got '$out'" # Placeholder after an agent glyph (post-strip match). - out=$(classify 0 '❯ Type a message...' "$idle") + out=$(LC_ALL=C LANG=C bash -c '. "$0/bin/fm-composer-lib.sh"; fm_composer_classify_content 0 "$1" "$2"' \ + "$ROOT" '❯ Type a message...' "$idle") [ "$out" = empty ] || fail "the idle placeholder after a glyph should read empty, got '$out'" # Without the idle regex it is just text -> pending. out=$(classify 1 'Type a message...') From a4ad42b8f0923a672afdd53eebde9afa1c9f6762 Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 17 Jul 2026 14:36:30 +0800 Subject: [PATCH 11/13] no-mistakes(document): Clarify session-lock refusal and Pi fail-closed docs --- AGENTS.md | 3 ++- docs/supervision-protocols/pi.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2c745f2df..d3795ec1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,8 @@ Read the complete digest once and trust it as this turn's startup and recovery i Do not separately re-read the context, backlog, metadata, or bulk status inputs it just printed unless a source was reported absent or corrupt, older history is specifically needed, or a targeted workflow must inspect before writing. An `ABSENT` captain, shared-captain, secondmate, or learnings file means template defaults, no shared captain preferences, no registered secondmates, or no captured learnings; rebuild an absent or stale project registry from the clones before dispatch. -If the session lock is refused, tell the captain another active session is managing the fleet and remain read-only. +If session-lock acquisition fails, report the exact `fm-lock.sh` refusal and remain read-only. +Only say another active session is managing the fleet when that refusal identifies a verified live holder. A lock-refused session must not spawn, steer, merge, drain the wake queue, repair supervision, repair a checkout, or perform any other fleet mutation. 1. **Lock** - acquires the per-home session lock first, before anything mutates shared state. diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index f48aaa406..4e30a5992 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -6,6 +6,7 @@ When this session owns supervision and away mode is not active: 3. On `session_start`, the watcher extension first requires `bin/fm-primary-scope.sh` to identify a main or valid secondmate primary home, then reads ownership through `bin/fm-lock.sh ownership`. A missing or dead lock is reacquired only through `bin/fm-lock.sh`, ownership is read again, and supervision starts only after the result is `owned`. A verified live other owner remains unchanged and the extension refuses to arm. + Any malformed or unclassifiable lock state, failed acquisition, or post-acquisition result other than `owned` fails closed without arming. When `state/.afk` exists, lock recovery still completes but the extension does not start or restart a watcher because the sub-supervisor owns supervision. If away mode begins after the extension has armed, its direct flag monitor stops that owned arm child and suppresses direct Pi wake delivery during the handoff. If the AFK launch transaction rolls the flag back, the extension restores normal watcher supervision automatically. From a6e3f8e7493a9dbdaa8375e4ee0b9c7987f5d4fc Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 17 Jul 2026 14:49:07 +0800 Subject: [PATCH 12/13] no-mistakes(document): Clarify unowned session lock refusal documentation --- AGENTS.md | 2 +- bin/fm-bootstrap.sh | 8 ++++---- bin/fm-guard.sh | 2 +- bin/fm-session-start.sh | 11 +++++------ 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d3795ec1c..94e4f9126 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,7 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai The five MUTATING sweeps - non-executing legacy PR-check migration, fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. The secondmate liveness sweep deterministically guarantees every registered secondmate is actually running: it probes each live secondmate's endpoint for a real agent process (not just pane presence), respawns only on a confident dead reading, and reports only skipped or failed guarantees as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_alive`). 3. **Wake queue** - when locked, drains the durable wake queue and prints the records prominently as this turn's first work queue, exactly as `bin/fm-wake-drain.sh` did before; a lapsed watcher chain still surfaces here via the same guard alarm. - When the lock could not be acquired, the queue is left untouched because another session owns it, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. + When the lock could not be acquired, the queue is left untouched because this session has not established ownership, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. 4. **Context digest** - the full contents of `data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`, each clearly delimited. A file that does not exist prints an explicit `ABSENT` marker, never confused with an empty-but-present file: absence is meaningful (`captain.md` absent means use this template's defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.). 5. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 753c5cec3..6912b0b91 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -71,10 +71,10 @@ # x_mode_setup, fleet_sync) while still printing every read-only detect line # above; the TANGLE line switches to advisory-only wording with no # checkout command. Used by -# fm-session-start.sh's read-only path when another live session holds -# the fleet lock, so a second concurrent session never race-mutates -# PR-check artifacts, secondmate homes, X-mode artifacts, project -# clones, or repair instructions. +# fm-session-start.sh's read-only path whenever session-lock acquisition +# fails, so a session without verified ownership never race-mutates +# PR-check artifacts, secondmate homes, X-mode artifacts, project clones, +# or repair instructions. # Unset/0 (the default) runs every sweep exactly as before - this flag # is purely additive. # fm-bootstrap.sh install ... diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 024491def..7135a8500 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Watcher liveness and worktree-tangle guard, called by supervision scripts, by # fm-wake-drain.sh after it empties queued wakes, and by fm-session-start.sh in -# read-only advisory mode when another session holds the fleet lock. +# read-only advisory mode when session start did not acquire the fleet lock. # First, always warn if the firstmate primary checkout (FM_ROOT) is on a named # non-default branch, because that means firstmate-on-itself work landed in the # primary instead of an isolated worktree. diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 592b28f48..7ab580c08 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -61,8 +61,8 @@ # mode) for its read-only detect lines - missing tools, gh auth, the # worktree-tangle check, the harness override, crew-dispatch validation, # tasks-axi and quota-axi tool checks, and tasks-axi availability - none of -# which mutate shared state and all of which are safe to compute from a second -# session. +# which mutate shared state and all of which are safe to compute without lock +# ownership. # Only the five mutating sweeps and the wake-queue drain are skipped. # The context and fleet-state digests # below are always read-only, so they run unconditionally in both modes. @@ -279,10 +279,9 @@ fi # Drained records are this turn's first work queue (AGENTS.md section 8); the # drain also runs fm-guard.sh internally on the locked path, so the # tangle/watcher-liveness alarms land right here too, ahead of the bulk digest -# below. The read-only path never touches the queue (another session -# may be actively draining it) but still runs fm-guard.sh directly with -# non-mutating advisory text, so the same alarms surface without repair -# commands. +# below. The read-only path never touches the queue because this session did not +# establish ownership, but still runs fm-guard.sh directly with non-mutating +# advisory text, so the same alarms surface without repair commands. subsection "WAKE QUEUE" if [ "$READ_ONLY" -eq 1 ]; then QLEN=0 From 88118f14500c8433b827385f0246b9bcdd887da0 Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 17 Jul 2026 15:01:01 +0800 Subject: [PATCH 13/13] no-mistakes(document): Clarify session lock refusal output --- bin/fm-bootstrap.sh | 2 +- bin/fm-guard.sh | 4 +-- bin/fm-session-start.sh | 8 ++--- bin/fm-supervision-instructions.sh | 2 +- tests/fm-session-start.test.sh | 40 ++++++++++++++++++++--- tests/fm-supervision-instructions.test.sh | 3 +- tests/fm-tangle-guard.test.sh | 6 ++-- 7 files changed, 50 insertions(+), 15 deletions(-) diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 6912b0b91..eaeff8b0d 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -784,7 +784,7 @@ tangle_branch=$(fm_primary_tangle_branch "$FM_ROOT" 2>/dev/null || true) if [ -n "$tangle_branch" ]; then tangle_default=$(fm_default_branch "$FM_ROOT" 2>/dev/null || echo main) if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" = 1 ]; then - echo "TANGLE: primary checkout on feature branch '$tangle_branch' (expected '$tangle_default'); the work is safe on that ref - read-only session must leave restore work to the session holding the fleet lock" + echo "TANGLE: primary checkout on feature branch '$tangle_branch' (expected '$tangle_default'); the work is safe on that ref - this read-only session must not restore it without the fleet lock" else echo "TANGLE: primary checkout on feature branch '$tangle_branch' (expected '$tangle_default'); the work is safe on that ref - restore the primary with: git -C $FM_ROOT checkout $tangle_default, then re-validate the branch in a proper worktree" fi diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 7135a8500..a78de7b5e 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -130,7 +130,7 @@ if [ -n "$tangle_branch" ]; then printf '● A crewmate likely branched/committed in the primary instead of its own worktree.\n' printf "● The work is SAFE on the '%s' ref.\n" "$tangle_branch" if [ "$READ_ONLY" -eq 1 ]; then - printf '● This read-only session must leave restore work to the session holding the fleet lock.\n' + printf '● This read-only session must not restore it without the fleet lock.\n' else printf "● Restore the primary to '%s':\n" "$tangle_default" printf '● git -C %s checkout %s\n' "$FM_ROOT" "$tangle_default" @@ -212,7 +212,7 @@ fi # Dedup of the watcher-down banner never suppresses this warning. if "$queue_pending"; then if [ "$READ_ONLY" -eq 1 ]; then - echo "WARNING: queued wakes pending - left untouched for the session holding the fleet lock." >&2 + echo "WARNING: queued wakes pending - left untouched because this session did not acquire the fleet lock." >&2 else echo "WARNING: queued wakes pending - drain them with bin/fm-wake-drain.sh before anything else." >&2 fi diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 7ab580c08..f18ac73aa 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -251,7 +251,7 @@ if [ "$LOCK_RC" -ne 0 ]; then BAR='●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' { printf '%s\n' "$BAR" - printf '● READ-ONLY SESSION - ANOTHER LIVE FIRSTMATE SESSION HOLDS THE FLEET LOCK\n' + printf '● READ-ONLY SESSION - SESSION LOCK ACQUISITION WAS REFUSED\n' printf '● %s\n' "$LOCK_OUT" printf '● Skipping every mutating step: PR-check migration, secondmate sync,\n' printf '● X-mode artifacts, fleet sync, and wake-queue drain. Detect-only bootstrap\n' @@ -286,7 +286,7 @@ subsection "WAKE QUEUE" if [ "$READ_ONLY" -eq 1 ]; then QLEN=0 [ -s "$STATE/.wake-queue" ] && QLEN=$(grep -c . "$STATE/.wake-queue" 2>/dev/null || printf '0') - printf 'skipped (read-only session) - %s record(s) remain queued for the session holding the lock.\n' "$QLEN" + printf 'skipped (read-only session) - %s record(s) remain queued and untouched because this session did not acquire the fleet lock.\n' "$QLEN" GUARD_OUT=$(FM_GUARD_READ_ONLY=1 "$SCRIPT_DIR/fm-guard.sh" 2>&1) [ -n "$GUARD_OUT" ] && printf '%s\n' "$GUARD_OUT" else @@ -390,8 +390,8 @@ section "NEXT STEP" if [ "$READ_ONLY" -eq 1 ]; then cat <<'EOF' This session did not acquire the fleet lock. Stay read-only: do not arm, -drain, spawn, steer, merge, or repair fleet state from here. The session -holding the lock owns mutable follow-up. +drain, spawn, steer, merge, or repair fleet state from here. Mutable follow-up +requires a session that has acquired the lock. EOF elif [ "$AFK_PRESENT" -eq 1 ]; then diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 3da0ac19d..5c2342be3 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -116,7 +116,7 @@ render_snippet() { repair_line() { if [ "$READ_ONLY" -eq 1 ]; then - printf '%s\n' 'Watcher repair belongs to the session holding the fleet lock; do not drain, arm, or repair from this read-only session.' + printf '%s\n' 'Watcher repair requires a session that has acquired the fleet lock; do not drain, arm, or repair from this read-only session.' return 0 fi if [ "$AFK" -eq 1 ]; then diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 6ae0d124b..cf01e3167 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -359,15 +359,17 @@ EOF wait "$holder_pid" 2>/dev/null || true expect_code 0 "$status" "fm-session-start.sh must exit 0 even on a lock refusal" - assert_contains "$out" "READ-ONLY SESSION" "read-only banner missing on lock refusal" + assert_contains "$out" "READ-ONLY SESSION - SESSION LOCK ACQUISITION WAS REFUSED" "read-only banner did not report the generic refusal" assert_contains "$out" "another live firstmate session holds the lock" "read-only banner did not surface fm-lock.sh's own error text" assert_contains "$out" "Skipping every mutating step" "read-only banner did not explain what was skipped" - assert_contains "$out" "skipped (read-only session)" "wake-queue section did not report itself skipped" + assert_contains "$out" "remain queued and untouched because this session did not acquire the fleet lock" "wake-queue section did not explain its owner-neutral refusal behavior" assert_contains "$out" "WATCHER DOWN - SUPERVISION IS OFF" "read-only guard did not surface watcher-liveness alarm" - assert_contains "$out" "queued wakes pending - left untouched for the session holding the fleet lock" "read-only guard did not leave queued wakes to the lock holder" + assert_contains "$out" "queued wakes pending - left untouched because this session did not acquire the fleet lock" "read-only guard did not leave queued wakes untouched" assert_contains "$out" "TANGLE: primary checkout on feature branch 'fm/read-only-tangle'" "read-only bootstrap did not surface the tangle diagnostic" - assert_contains "$out" "read-only session must leave restore work" "read-only tangle diagnostic did not explain restore ownership" + assert_contains "$out" "read-only session must not restore it without the fleet lock" "read-only tangle diagnostic did not explain restore safety" assert_contains "$out" "Stay read-only: do not arm" "read-only next step did not block direct watcher repair" + assert_contains "$out" "Mutable follow-up" "read-only next step omitted mutable follow-up guidance" + assert_contains "$out" "requires a session that has acquired the lock" "read-only next step omitted its ownership requirement" assert_not_contains "$out" "drain them with bin/fm-wake-drain.sh" "read-only guard printed a mutating drain instruction" assert_not_contains "$out" "After draining queued wakes" "read-only guard printed a drain-then-rearm instruction" assert_not_contains "$out" "run bin/fm-watch-arm.sh" "read-only guard printed a mutating watcher-arm instruction" @@ -391,6 +393,35 @@ EOF pass "a lock refusal prints a loud read-only banner, skips every mutating step, and still completes the digest" } +test_malformed_lock_refusal_does_not_invent_live_owner() { + local rec root home fakebin out status + rec=$(new_world malformed-lock-refusal) + IFS='|' read -r root home fakebin < "$home/state/.lock" + printf 'window=firstmate:fm-malformed\nkind=ship\n' > "$home/state/malformed.meta" + append_wake "$home/state" signal malformed "done: queued before malformed-lock refusal" || fail "seed malformed-lock wake failed" + + status=0 + out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") || status=$? + + expect_code 0 "$status" "fm-session-start.sh must complete its digest after a malformed-lock refusal" + assert_contains "$out" "READ-ONLY SESSION - SESSION LOCK ACQUISITION WAS REFUSED" "malformed-lock banner did not report the generic refusal" + assert_contains "$out" "error: session lock is malformed; ownership was not changed" "malformed-lock banner lost fm-lock.sh's exact error" + assert_contains "$out" "remain queued and untouched because this session did not acquire the fleet lock" "malformed-lock queue guidance did not remain owner-neutral" + assert_contains "$out" "Watcher repair requires a session that has acquired the fleet lock" "malformed-lock repair guidance did not remain owner-neutral" + assert_contains "$out" "Mutable follow-up" "malformed-lock next step omitted mutable follow-up guidance" + assert_contains "$out" "requires a session that has acquired the lock" "malformed-lock next step did not remain owner-neutral" + assert_not_contains "$out" "ANOTHER LIVE FIRSTMATE SESSION" "malformed-lock banner invented a live owner" + assert_not_contains "$out" "another live firstmate session" "malformed-lock detail invented a live owner" + assert_not_contains "$out" "session holding the fleet lock" "malformed-lock helper guidance invented a current lock holder" + + pass "a malformed-lock refusal stays read-only without inventing a live owner" +} + # --- output ordering ---------------------------------------------------------- test_output_ordering_diagnostics_lead() { @@ -903,6 +934,7 @@ EOF test_context_digest_absent_empty_present test_lock_refusal_read_only_path +test_malformed_lock_refusal_does_not_invent_live_owner test_output_ordering_diagnostics_lead test_herdr_backend_diagnostics_follow_real_session_start test_status_tail_bounding diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index c1072063a..708c26593 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -59,7 +59,8 @@ test_repair_lines() { assert_contains "$out" "bin/fm-watch-checkpoint.sh --seconds 7" "x-mode codex repair line lost the checkpoint helper" out=$(FM_HOME="$home" "$RENDER" --harness opencode --read-only 1 --repair-line) - assert_contains "$out" "session holding the fleet lock" "read-only repair line missing" + assert_contains "$out" "requires a session that has acquired the fleet lock" "read-only repair line missing its ownership requirement" + assert_not_contains "$out" "session holding the fleet lock" "read-only repair line invented a current lock holder" out=$(FM_HOME="$home" "$RENDER" --harness pi --repair-line) assert_contains "$out" "Pi tool fm_watch_arm_pi" "pi repair line does not direct the model to the extension-owned tool" diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index a6f24eaa5..d7a466141 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -85,7 +85,8 @@ test_guard_banner() { assert_contains "$out" "checkout main" "guard banner did not print the restore remediation" out=$(FM_GUARD_READ_ONLY=1 run_guard "$repo") assert_contains "$out" "WORKTREE TANGLE" "read-only guard did not keep the tangle alarm" - assert_contains "$out" "read-only session must leave restore work" "read-only guard did not explain restore ownership" + assert_contains "$out" "read-only session must not restore it without the fleet lock" "read-only guard did not explain restore safety" + assert_not_contains "$out" "session holding the fleet lock" "read-only guard invented a current lock holder" assert_not_contains "$out" "checkout main" "read-only guard printed a state-changing restore command" pass "fm-guard: bordered tangle banner fires only for a feature branch and suppresses repair commands in read-only mode" } @@ -114,7 +115,8 @@ test_bootstrap_line() { assert_contains "$out" "checkout main" "bootstrap TANGLE line lacked the restore remediation" out=$(FM_ROOT_OVERRIDE="$repo" FM_HOME="$repo" FM_BOOTSTRAP_DETECT_ONLY=1 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null | grep '^TANGLE:' || true) assert_contains "$out" "fm/tangle-bb2" "detect-only bootstrap did not report the tangled branch" - assert_contains "$out" "read-only session must leave restore work" "detect-only bootstrap did not explain restore ownership" + assert_contains "$out" "read-only session must not restore it without the fleet lock" "detect-only bootstrap did not explain restore safety" + assert_not_contains "$out" "session holding the fleet lock" "detect-only bootstrap invented a current lock holder" assert_not_contains "$out" "checkout main" "detect-only bootstrap printed a state-changing restore command" pass "fm-bootstrap: TANGLE problem line fires only for a feature branch and suppresses repair commands in detect-only mode" }