diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 1e44bff54..40c4131be 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -211,11 +211,12 @@ 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`, 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 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 644f939ce..44c857eec 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, 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"; @@ -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,50 @@ 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 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")}`; let child: any = null; +let armReady = false; +let armReadiness: Promise | null = 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"; + return "unknown"; } -function sessionOwnsLock(): boolean { - return lockOwnership() === "owned"; +function isPrimaryHome(): boolean { + const result = spawnSync(scopeScript, [], { + encoding: "utf8", + env: { + ...lockEnvironment(), + FM_ROOT_OVERRIDE: root, + }, + }); + return result.status === 0; } -function markLoaded(): void { - if (lockOwnership() === "other") return; +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`); } @@ -86,27 +86,183 @@ 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; + 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 readAwayFlag(): boolean { + try { + statSync(awayFlag); + return true; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false; + throw error; + } + } + + 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 sendWakeSafely(result.message); + } + + const awayMonitorListener = () => { + void reconcileAwayOwnership().catch((error) => { + handleAwayMonitorFailure(error); + }); + }; + + 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; + const delay = awayMonitorRetryDelayMs; + awayMonitorRetryDelayMs = Math.min(awayMonitorRetryDelayMs * 2, 30000); + awayMonitorRetry = setTimeout(() => { + awayMonitorRetry = null; + startAwayMonitor(); + }, delay); + awayMonitorRetry.unref(); + } + + function startAwayMonitor(): boolean { + if (awayMonitor) return true; + if (!primaryHome) return false; + let awayActive: boolean; + try { + awayActive = readAwayFlag(); + awayMonitor = setInterval(awayMonitorListener, 50); + awayMonitor.unref(); + } catch (error) { + handleAwayMonitorFailure(error); + return false; + } + awayMonitorError = ""; + awayMonitorRetryDelayMs = 250; + if (awayMonitorRetry) { + clearTimeout(awayMonitorRetry); + awayMonitorRetry = null; + } + awayObserved = awayActive; + if (awayObserved) stopArm(); + markLoadedForPrimary(); + if (sessionActive && !awayObserved && !child) { + void recoverArmAfterMonitor(); + } + return true; + } + + function stopAwayMonitor(): void { + if (awayMonitor) clearInterval(awayMonitor); + awayMonitor = null; + if (awayMonitorRetry) { + clearTimeout(awayMonitorRetry); + awayMonitorRetry = null; + } + awayMonitorRetryDelayMs = 250; } const cleanupOnProcessExit = () => { + shuttingDown = true; + sessionActive = false; + stopAwayMonitor(); stopArm(); }; process.once("exit", cleanupOnProcessExit); async function sendWake(message: string) { + 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" }, ); } - function startArm(): ArmResult { - if (!sessionOwnsLock()) return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; - markLoaded(); - if (child) return { ok: true, message: "watcher: healthy - Pi extension already has an arm child" }; + 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 (!primaryHome) { + 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; + 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}` }; + } + markLoadedForPrimary(); + if (existsSync(awayFlag)) { + 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, @@ -115,45 +271,75 @@ 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) => { + const intentionallyStopped = intentionalStops.has(armChild); + 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 (intentionallyStopped || (!reason && !failure)) return; + await sendWakeSafely(reason || failure); + }); + 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 }); + await sendWakeSafely(failure); + }); }); - return { ok: true, message: `watcher: started Pi extension arm child ${id}` }; + armReadiness = readiness; + return readiness; } - pi.on?.("session_start", () => { - markLoaded(); + pi.on?.("session_start", async (_event, ctx) => { + if (!primaryHome) return; + shuttingDown = false; + sessionActive = true; + startAwayMonitor(); + const result = await startArm(); + if (!result.ok) ctx?.ui?.notify?.(result.message, "warning"); }); pi.on?.("session_shutdown", () => { + shuttingDown = true; + sessionActive = false; + stopAwayMonitor(); stopArm(); process.off("exit", cleanupOnProcessExit); }); @@ -161,7 +347,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"); }, }); @@ -176,7 +362,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, @@ -184,5 +370,5 @@ export default function (pi: ExtensionAPI) { }, }); - markLoaded(); + startAwayMonitor(); } 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/AGENTS.md b/AGENTS.md index 2c745f2df..94e4f9126 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. @@ -130,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/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-bootstrap.sh b/bin/fm-bootstrap.sh index 753c5cec3..eaeff8b0d 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 ... @@ -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-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/bin/fm-guard.sh b/bin/fm-guard.sh index 024491def..a78de7b5e 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. @@ -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-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/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-session-start.sh b/bin/fm-session-start.sh index 592b28f48..f18ac73aa 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. @@ -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' @@ -279,15 +279,14 @@ 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 [ -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 @@ -391,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/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/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/docs/scripts.md b/docs/scripts.md index d0815745c..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) | @@ -73,7 +74,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..4e30a5992 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -3,13 +3,23 @@ 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 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. + 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. 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 +36,19 @@ 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 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 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`. +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/docs/turnend-guard.md b/docs/turnend-guard.md index 344417161..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. @@ -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,24 @@ 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`; `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. +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, 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. +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. +`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`. 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-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-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" \ 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...') diff --git a/tests/fm-pi-primary-live-e2e.test.sh b/tests/fm-pi-primary-live-e2e.test.sh index a7b86c913..bbaa0d946 100755 --- a/tests/fm-pi-primary-live-e2e.test.sh +++ b/tests/fm-pi-primary-live-e2e.test.sh @@ -14,10 +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_ID=11111111-1111-4111-8111-111111111111 PI_VERSION=$(pi --version) fail() { @@ -25,6 +22,16 @@ fail() { exit 1 } +[ "$PI_VERSION" = 0.80.7 ] || fail "live resume regression requires Pi 0.80.7, found $PI_VERSION" +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 } @@ -55,6 +62,26 @@ 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 +} + +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) @@ -101,30 +128,180 @@ 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" -mkdir -p "$HOME_DIR/state" "$HOME_DIR/config" "$PI_DIR" +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" + +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' +#!/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 --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 --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" +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" +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." 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,14 +309,14 @@ 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" 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" @@ -147,8 +324,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 rendered the tool, guarded once, 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" diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 6fd36a3ff..a8fe3f50a 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -14,8 +14,11 @@ 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" + 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 @@ -26,6 +29,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" "$repo/bin/fm-primary-scope.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 +95,31 @@ 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" "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 (!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" + 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 = await startArm();' "tracked extension does not await recovery and verified arm readiness" + 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" + 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" @@ -121,7 +194,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); } @@ -157,7 +230,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' @@ -197,6 +270,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" @@ -249,6 +378,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" @@ -293,6 +423,491 @@ 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, unlinkSync, 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); +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`); +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`); +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 and restores rollback supervision" +} + +test_pi_extension_fails_closed_and_retries_away_monitor() { + 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-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"; + +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 (["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); +}; +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); + }, + registerCommand() {}, + registerTool() {}, + sendUserMessage: async (message) => { + if (process.env.CASE === "delivery") throw new Error("fixture delivery rejection"); + 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 (!["runtime", "delivery"].includes(process.env.CASE) && existsSync(marker)) { + throw new Error("extension advertised loaded before away monitoring was active"); +} +if (["runtime", "delivery"].includes(process.env.CASE) && !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") { + 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 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"); + 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(" | ")}`); +} 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 + ) + 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 contains delivery rejection and backs off monitor failures" +} + +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 + 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:?}" +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" + 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 === "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; + +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 + : 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)); +} + +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 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}`); + 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 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() { + 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" @@ -739,8 +1354,14 @@ 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_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 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-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" } diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 3e5d54f82..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() { @@ -758,6 +759,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" 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"