diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index fd6926b2183..d4ee9678433 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -44,6 +44,9 @@ When any diagnostic needs captain attention, report the plain consequence and re Publication is deliberately best-effort, so it cannot change another session-start, spawn, teardown, or watcher-poll result, and the watcher runs it detached so a slow attempt cannot delay the liveness beacon. Read the named record for the recorded reasons, then reproduce with a direct `bin/fm-home-summary-refresh.sh` (no `--best-effort`, which is what keeps the failure quiet) so the refresh error reaches you. A recorded deadline means the complete refresh did not finish inside `FM_HOME_SUMMARY_TIMEOUT`, so inspect lock acquisition and producer completion before validation or publication, and fix the blocked phase rather than raising this load-bearing bound. +- `LAVISH_REGISTRY_WARNING: ; ... run bin/fm-lavish-audit.sh audit` - the historical Lavish registry has reached 50 open rows, or its read-only audit could not complete. + Run the named audit, preserve every ambiguous or actively owned review, and never treat registry-row count as live-connection count. + Bootstrap never prunes sessions. - `BOOTSTRAP_INFO: closed the backlog item for after interrupted cleanup; its endpoint or local copy may remain and should be reconciled` - replay closed the item, but the durable transition says physical cleanup was interrupted. Verify process reaping, the local-copy return, and endpoint closure, then reconcile any surviving resource. diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index 2e19cbcd4f4..5e18af12229 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -27,9 +27,16 @@ Use the adapter, not the generic runner, for a real source. For a Lavish review artifact produced by a worker (a live investigating scout should host its own loop and author revisions): ```sh -bin/fm-procevent-lavish.sh arm +bin/fm-procevent-lavish.sh arm --task-id ``` +The adapter records the task's Lavish ownership ledger as part of arming. +The fleet bearings board is home-owned and uses `--task-id home`. +Use `bin/fm-lavish-session.sh safe-park ` when a review must outlive its worktree; that command owns the copy, re-serve, binding transfer, live verification, and superseded-session end sequence. +Use `bin/fm-procevent-lavish.sh retire-and-end ` only when the durable review's owning lifecycle has reached its terminal event. +That operation preflights the durable end guard before retiring the source, then verifies the end. +Plain `retire` remains the correct narrow operation for a listener replacement that must leave the review open. + When a source carries captain answers to captain-held tasks, bind it BEFORE arming it, so it can never produce an answer that has nowhere to go: ```sh diff --git a/.gitignore b/.gitignore index dd0a8f1df19..320eb3b897c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ projects/ state/ -data/ +data/* +!data/fm-lavish-session-prune-f1/ +data/fm-lavish-session-prune-f1/* scratchpad* .no-mistakes/ .lavish/ diff --git a/bin/fm-bearings-board.sh b/bin/fm-bearings-board.sh index cff3cfb69cc..3c0092a41d9 100755 --- a/bin/fm-bearings-board.sh +++ b/bin/fm-bearings-board.sh @@ -48,6 +48,7 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-$FM_ROOT}" +LAVISH_STATE_FILE="${FM_LAVISH_STATE_FILE:-${LAVISH_AXI_STATE_DIR:-$HOME/.lavish-axi}/state.json}" TEMPLATE="${FM_BEARINGS_BOARD_TEMPLATE:-$SCRIPT_DIR/../.agents/skills/bearings/assets/board-template.html}" PLACEHOLDER='__FM_BEARINGS_BOARD_DATA__' @@ -66,7 +67,13 @@ fail() { exit 1 } +# shellcheck source=bin/fm-lavish-lib.sh +. "$SCRIPT_DIR/fm-lavish-lib.sh" +LAVISH_STATE_DIR=$(fm_lavish_state_dir "$LAVISH_STATE_FILE") \ + || fail "FM_LAVISH_STATE_FILE must be an absolute Lavish state.json path" + board_path() { printf '%s/.lavish/bearings-board.html\n' "$FM_HOME"; } +lavish_cli() { LAVISH_AXI_STATE_DIR="$LAVISH_STATE_DIR" command lavish-axi "$@"; } validate_payload() { # jq -e --arg schema "$BOARD_SCHEMA" ' @@ -137,7 +144,7 @@ validate_payload() { # } command_build() { - local data=${1-} board json tmp sid extracted + local data=${1-} board json tmp sid extracted state_override [ "$#" -eq 1 ] || { usage >&2; exit 2; } command -v jq >/dev/null 2>&1 || fail "jq is required" [ -f "$data" ] || fail "board data does not exist: $data" @@ -178,7 +185,7 @@ command_build() { printf 'board: %s\n' "$board" command -v lavish-axi >/dev/null 2>&1 || fail "lavish-axi is not installed" - lavish-axi "$board" || fail "cannot establish the board Lavish session" + lavish_cli "$board" || fail "cannot establish the board Lavish session" printf 'served: %s\n' "$board" sid=$("$SCRIPT_DIR/fm-procevent-lavish.sh" source-id "$board") \ @@ -188,9 +195,13 @@ command_build() { printf 'bound: %s\n' "$sid" if "$SCRIPT_DIR/fm-procevent.sh" list | awk 'NR > 1 { print $1 }' | grep -Fxq "$sid"; then + state_override=${FM_STATE_OVERRIDE:-$FM_HOME/state} + FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$state_override" \ + "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$board" home >/dev/null \ + || fail "cannot refresh the board Lavish ownership ledger" printf 'already-armed: %s\n' "$sid" else - "$SCRIPT_DIR/fm-procevent-lavish.sh" arm "$board" >/dev/null \ + "$SCRIPT_DIR/fm-procevent-lavish.sh" arm "$board" --task-id home >/dev/null \ || fail "cannot arm the board as a process-event source" printf 'armed: %s\n' "$sid" fi diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 28320608e20..16c423d0331 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -1466,6 +1466,29 @@ detect_local_config() { echo "BOOTSTRAP_INFO: tasks-axi available" fi detect_home_summary_publication + detect_lavish_registry +} + +# Lavish's registry is historical state, not a live-connection count. +# This read-only startup diagnostic stays silent below the 20-session target, +# reports a non-actionable fact from 20 through 49, and emits one actionable +# warning from 50 upward. +detect_lavish_registry() { + local lavish_state summary open + lavish_state="${FM_LAVISH_STATE_FILE:-${LAVISH_AXI_STATE_DIR:-$HOME/.lavish-axi}/state.json}" + [ -f "$lavish_state" ] && [ ! -L "$lavish_state" ] || return 0 + summary=$(FM_HOME="$FM_HOME" FM_LAVISH_STATE_FILE="$lavish_state" \ + "$SCRIPT_DIR/fm-lavish-audit.sh" summary 2>/dev/null) || { + echo "LAVISH_REGISTRY_WARNING: registry audit failed; run bin/fm-lavish-audit.sh audit" + return 0 + } + open=$(printf '%s\n' "$summary" | sed -n 's/.* registry rows: .* open=\([0-9][0-9]*\).*/\1/p') + case "$open" in ''|*[!0-9]*) echo "LAVISH_REGISTRY_WARNING: registry count was unreadable; run bin/fm-lavish-audit.sh audit"; return 0 ;; esac + if [ "$open" -ge 50 ]; then + printf 'LAVISH_REGISTRY_WARNING: %s; these are historical registry rows, not live connections; run bin/fm-lavish-audit.sh audit\n' "$summary" + elif [ "$open" -ge 20 ]; then + printf 'BOOTSTRAP_INFO: %s; these are historical registry rows, not live connections\n' "$summary" + fi } # This home's ledger publication is deliberately best-effort: every lifecycle diff --git a/bin/fm-lavish-audit.sh b/bin/fm-lavish-audit.sh new file mode 100755 index 00000000000..174cf9dc175 --- /dev/null +++ b/bin/fm-lavish-audit.sh @@ -0,0 +1,722 @@ +#!/usr/bin/env bash +# Audit the Lavish registry against Firstmate lifecycle ownership without mutation. +# +# Usage: +# fm-lavish-audit.sh [audit] [--freeze ] [--expiry-hours ] +# [--preserve-paths ] +# fm-lavish-audit.sh summary +# fm-lavish-audit.sh guard [--allow-source ] +# fm-lavish-audit.sh apply [--authorized []] +# [--batch-size <1..50>] [--expiry-hours ] +# [--preserve-paths ] +# +# audit is the default and classifies every registry row as preserve, eligible, +# or ambiguous with evidence. +# --freeze atomically writes only eligible existing-path rows to a mode-0600 +# JSONL candidate file, including the audited status and updated_at values. +# apply accepts only that frozen shape, reclassifies every row against current +# state, ends bounded batches through `lavish-axi end `, verifies +# each transition, and recounts after each batch. +# --authorized accepts captain-authorized ambiguous existing-path rows from a +# frozen authority file carrying the 2026-09-08 ruling and three exclusions. +# It never deletes Lavish records, Firstmate state, chat, attachments, or files, +# and it never edits Lavish state.json directly. +# +# Ownership is read across this FM_HOME and every home registered in its +# data/secondmates.md. +# Remote or unreadable homes remain uncertainty rather than permission to end. +# Browser/session keys that cannot be observed from the registry are accepted +# from FM_LAVISH_ATTACHED_KEYS_FILE, one `` row per client; +# registered and live agent poll ownership is discovered directly. +# The default idle expiry is 48 hours. +# A re-serve updates Lavish's updated_at, and an arm records last_polled_at in +# the ownership ledger; an active poll remains a preserve condition regardless +# of age. +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}}" +LAVISH_STATE_FILE="${FM_LAVISH_STATE_FILE:-${LAVISH_AXI_STATE_DIR:-$HOME/.lavish-axi}/state.json}" +AUTHORIZATION_DEFAULT="$FM_ROOT/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json" +AUTHORIZATION_RULING='Apply only captain-authorized ambiguous existing-path sessions, except the three links mentioned on 2026-09-08.' + +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" +# shellcheck source=bin/fm-lavish-lib.sh +. "$SCRIPT_DIR/fm-lavish-lib.sh" + +die() { printf 'error: %s\n' "$1" >&2; exit 1; } +LAVISH_STATE_DIR=$(fm_lavish_state_dir "$LAVISH_STATE_FILE") \ + || die "FM_LAVISH_STATE_FILE must be an absolute Lavish state.json path" +usage() { sed -n '2,/^set -u$/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; exit 2; } + +lavish_cli() { LAVISH_AXI_STATE_DIR="$LAVISH_STATE_DIR" command lavish-axi "$@"; } + +lavish_axi_port() { + local port=${LAVISH_AXI_PORT:-4387} + case "$port" in ''|*[!0-9]*) return 1 ;; esac + [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || return 1 + printf '%s\n' "$port" +} + +make_homes_file() { + local out=$1 registry="$FM_HOME/data/secondmates.md" line home + : > "$out" || die "cannot stage home inventory" + printf '%s\n' "$FM_HOME" >> "$out" || die "cannot stage home inventory" + if [ -L "$registry" ]; then + die "secondmate registry is unavailable or unsafe: $registry" + fi + if [ -e "$registry" ]; then + [ -f "$registry" ] || die "secondmate registry is not a regular file: $registry" + secondmate_registry_validate_bindings "$registry" secondmate_registry_path_key \ + || die "${SECONDMATE_REGISTRY_ERROR:-secondmate registry validation failed}" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "- "*) + secondmate_registry_parse_line "$line" \ + || die "malformed secondmate registry entry: $line" + home=$SECONDMATE_REGISTRY_HOME + printf '%s\n' "$home" >> "$out" || die "cannot stage home inventory" + ;; + esac + done < "$registry" || die "cannot read secondmate registry: $registry" + fi + awk '!seen[$0]++' "$out" > "$out.unique" \ + || die "cannot deduplicate home inventory" + mv -f "$out.unique" "$out" || die "cannot publish home inventory" +} + +run_audit_node() { + local mode=$1 homes_file=$2 freeze=${3-} guard_task=${4-} guard_file=${5-} guard_key=${6-} guard_home=${7-} guard_allow_source=${8-} port + port=$(lavish_axi_port) || return 1 + AUDIT_MODE="$mode" HOMES_FILE="$homes_file" FREEZE_FILE="$freeze" \ + LAVISH_STATE_FILE="$LAVISH_STATE_FILE" ATTACHED_FILE="${FM_LAVISH_ATTACHED_KEYS_FILE:-}" \ + ACTIVE_PORT="$port" \ + EXPIRY_HOURS="${FM_LAVISH_IDLE_EXPIRY_HOURS:-48}" PRESERVE_PATHS_FILE="${FM_LAVISH_PRESERVE_PATHS_FILE:-}" \ + GUARD_TASK="$guard_task" GUARD_FILE="$guard_file" GUARD_KEY="$guard_key" GUARD_HOME="$guard_home" GUARD_ALLOW_SOURCE="$guard_allow_source" \ + node <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const cp = require("node:child_process"); + +const fail = message => { console.error(`error: ${message}`); process.exit(1); }; +const isObject = value => value && typeof value === "object" && !Array.isArray(value); +const isString = value => typeof value === "string"; +const isSlug = value => isString(value) && /^[A-Za-z0-9._-]+$/.test(value); +let state; +try { state = JSON.parse(fs.readFileSync(process.env.LAVISH_STATE_FILE, "utf8")); } +catch (error) { fail(`cannot read Lavish state: ${error.message}`); } +if (!isObject(state.sessions)) fail("Lavish state has no session registry object"); +let homes; +try { homes = fs.readFileSync(process.env.HOMES_FILE, "utf8").split("\n").filter(Boolean); } +catch (error) { fail(`cannot read home inventory: ${error.message}`); } + +const meta = []; +const closed = new Set(); +const held = new Map(); +const ledgers = new Map(); +const sources = new Set(); +const decisions = new Set(); +const unacked = new Set(); +const attached = new Map(); +const sourceHomes = new Map(); +const preservePaths = new Set(); +const inventoryErrors = []; +let unreadableHome = false; +let activePollRegistrations = 0; +const addInventoryError = (home, message) => { + unreadableHome = true; + inventoryErrors.push(`${home}: ${message}`); +}; +const addAttached = (key, kind) => { + if (!attached.has(key)) attached.set(key, []); + attached.get(key).push(kind); +}; +const addHeld = (key, kind) => { + if (!held.has(key)) held.set(key, []); + held.get(key).push(kind); +}; +const addLedger = (key, row) => { + if (!ledgers.has(key)) ledgers.set(key, []); + ledgers.get(key).push(row); +}; +const addSource = (sid, home) => { + sources.add(sid); + if (!sourceHomes.has(sid)) sourceHomes.set(sid, []); + sourceHomes.get(sid).push(home); +}; +const sourceId = file => `lavish-${crypto.createHash("sha256").update(file).digest("hex").slice(0,16)}`; + +const listDir = (home, dir, label, optional = true) => { + try { + const stat = fs.lstatSync(dir); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("not a safe directory"); + return fs.readdirSync(dir); + } catch (error) { + if (error.code === "ENOENT" && optional) return []; + addInventoryError(home, `${label} is unreadable: ${error.message}`); + return []; + } +}; +const readInventoryFile = (home, file, label, optional = true) => { + try { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("not a safe regular file"); + return fs.readFileSync(file, "utf8"); + } catch (error) { + if (optional && error.code === "ENOENT") return null; + addInventoryError(home, `${label} is unreadable: ${error.message}`); + return null; + } +}; +const regularArtifact = file => { + try { + const stat = fs.lstatSync(file); + return stat.isFile() && !stat.isSymbolicLink(); + } catch (error) { + if (error.code === "ENOENT") return false; + return false; + } +}; +const isUnder = (file, root) => file === root || file.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`); +const ownerKey = (home, task) => `${home}\0${task}`; +const parseFields = (text, file) => { + const fields = {}; + for (const line of text.split("\n")) { + if (!line) continue; + const index = line.indexOf("="); + if (index <= 0) throw new Error(`malformed line in ${file}`); + const key = line.slice(0, index); + if (Object.prototype.hasOwnProperty.call(fields, key)) throw new Error(`duplicate field in ${file}: ${key}`); + fields[key] = line.slice(index + 1); + } + return fields; +}; + +const normalizedHomes = []; +const seenHomes = new Set(); +for (const rawHome of homes) { + try { + const home = fs.realpathSync(rawHome); + const stat = fs.statSync(home); + if (!stat.isDirectory()) throw new Error("home is not a directory"); + if (seenHomes.has(home)) continue; + seenHomes.add(home); + normalizedHomes.push(home); + } catch (error) { + addInventoryError(rawHome, `home is unreadable: ${error.message}`); + } +} + +for (const home of normalizedHomes) { + const stateDir = path.join(home, "state"); + const dataDir = path.join(home, "data"); + const stateNames = listDir(home, stateDir, "state directory", false); + listDir(home, dataDir, "data directory", false); + + for (const name of stateNames.filter(item => item.endsWith(".meta"))) { + const task = name.slice(0, -5); + const file = path.join(stateDir, name); + if (!isSlug(task)) { + addInventoryError(home, `task metadata has an unsafe name: ${name}`); + continue; + } + const text = readInventoryFile(home, file, "task metadata", false); + if (text === null) continue; + try { + const fields = parseFields(text, file); + if (fields.kind === "secondmate") { + if (!isString(fields.worktree) || !fields.worktree || !path.isAbsolute(fields.worktree) || !isString(fields.home) || !fields.home || !path.isAbsolute(fields.home)) throw new Error("secondmate metadata has no safe absolute home and worktree"); + let secondmateHome; + try { secondmateHome = fs.realpathSync(fields.home); } catch (error) { throw new Error(`secondmate metadata home is unreadable: ${error.message}`); } + if (!normalizedHomes.includes(secondmateHome)) throw new Error("secondmate metadata home is not in registered home inventory"); + let worktree = fields.worktree; + try { worktree = fs.realpathSync(worktree); } catch (error) { if (error.code !== "ENOENT") throw error; } + meta.push({task,home:secondmateHome,worktree}); + continue; + } + if (!isString(fields.worktree) || !fields.worktree) throw new Error("task metadata has no worktree"); + if (!path.isAbsolute(fields.worktree)) throw new Error("task metadata worktree is not absolute"); + let worktree = fields.worktree; + try { worktree = fs.realpathSync(worktree); } catch (error) { if (error.code !== "ENOENT") throw error; } + meta.push({task,home,worktree}); + } catch (error) { + addInventoryError(home, error.message); + } + } + + const backlog = path.join(dataDir, "backlog.md"); + const backlogText = readInventoryFile(home, backlog, "backlog", true); + if (backlogText !== null) { + for (const line of backlogText.split("\n")) { + const done = line.match(/^- \[x\] ([A-Za-z0-9._-]+)(?: |$)/); + if (done) closed.add(ownerKey(home, done[1])); + const taskMatch = line.match(/^- \[[ x]\] ([A-Za-z0-9._-]+)(?: |$)/); + const holdMatch = line.match(/\(hold-kind: ([A-Za-z0-9._-]+)\)/); + if (taskMatch && holdMatch) addHeld(ownerKey(home, taskMatch[1]), holdMatch[1]); + } + } + + for (const name of stateNames.filter(item => item.endsWith(".lavish-sessions"))) { + const task = name.slice(0, -17); + const file = path.join(stateDir, name); + if (!isSlug(task)) { + addInventoryError(home, `Lavish ledger has an unsafe name: ${name}`); + continue; + } + const text = readInventoryFile(home, file, "Lavish ledger", false); + if (text === null) continue; + if (!text.trim()) { + addInventoryError(home, `Lavish ledger is empty: ${file}`); + continue; + } + for (const line of text.split("\n").filter(Boolean)) { + try { + const row = JSON.parse(line); + if (!isObject(row) || !isSlug(row.task_id) || row.task_id !== task || !isString(row.home) || !path.isAbsolute(row.home) || !isString(row.artifact) || !path.isAbsolute(row.artifact) || !isString(row.key) || !["ephemeral-worktree", "durable-review"].includes(row.disposition)) throw new Error(`malformed Lavish ledger row in ${file}`); + if ((row.url !== undefined && !isString(row.url)) || (row.created_at !== undefined && (!isString(row.created_at) || (row.created_at && !Number.isFinite(Date.parse(row.created_at))))) || (row.last_polled_at !== undefined && (!isString(row.last_polled_at) || (row.last_polled_at && !Number.isFinite(Date.parse(row.last_polled_at))))) || (row.ended_at !== undefined && (!isString(row.ended_at) || (row.ended_at && !Number.isFinite(Date.parse(row.ended_at))))) ) throw new Error(`malformed Lavish ledger timestamps or URL in ${file}`); + if (state.sessions[row.key] && state.sessions[row.key].status === "ended" && !row.ended_at) throw new Error(`active ledger row points to ended session ${row.key}`); + if (!state.sessions[row.key]) throw new Error(`active ledger row points to missing session ${row.key}`); + if (!row.ended_at) addLedger(row.key, row); + } catch (error) { + addInventoryError(home, error.message); + } + } + } + + const processEventDir = path.join(stateDir, "procevent"); + for (const name of listDir(home, processEventDir, "process-event directory").filter(item => /^lavish-[^.]+\.source$/.test(item))) { + const file = path.join(processEventDir, name); + const text = readInventoryFile(home, file, "process-event source", false); + if (text === null) continue; + if (!/^adapter=lavish\n/m.test(text) || !/^argv:\n/m.test(text)) addInventoryError(home, `malformed process-event source: ${file}`); + addSource(name.slice(0, -7), home); + activePollRegistrations++; + } + + const bindingDir = path.join(stateDir, "decision-bindings"); + for (const name of listDir(home, bindingDir, "decision-binding directory").filter(item => /^lavish-[^.]+\.origin$/.test(item))) { + const file = path.join(bindingDir, name); + const text = readInventoryFile(home, file, "decision binding", false); + if (text === null) continue; + decisions.add(name.slice(0, -7)); + } + + const inbox = path.join(stateDir, "procevent-inbox"); + for (const name of listDir(home, inbox, "process-event inbox").filter(item => /^lavish-[^.]+\.[0-9]+\.result$/.test(item))) { + const result = path.join(inbox, name); + const resultText = readInventoryFile(home, result, "process-event result", false); + if (resultText === null) continue; + const handled = path.join(inbox, name.replace(/\.result$/, ".handled")); + try { + const handledStat = fs.lstatSync(handled); + if (!handledStat.isFile() || handledStat.isSymbolicLink()) addInventoryError(home, `handled process-event marker is not a safe regular file: ${handled}`); + } catch (error) { + if (error.code === "ENOENT") unacked.add(name.replace(/\.[0-9]+\.result$/, "")); + else addInventoryError(home, `handled process-event marker is unreadable: ${handled}: ${error.message}`); + } + } +} + +if (process.env.PRESERVE_PATHS_FILE) { + const text = readInventoryFile("preserve-paths", process.env.PRESERVE_PATHS_FILE, "preserve-path evidence", false); + if (text === null) fail("cannot read preserve-path evidence"); + for (const line of text.split("\n")) if (line) preservePaths.add(line); +} + +const expiryHours = Number(process.env.EXPIRY_HOURS); +if (!Number.isFinite(expiryHours) || expiryHours < 0) fail("idle expiry hours must be a non-negative number"); +if (process.env.ATTACHED_FILE) { + const text = readInventoryFile("attached-client-evidence", process.env.ATTACHED_FILE, "attached-client evidence", false); + if (text === null) fail("cannot read attached-client evidence"); + for (const line of text.split("\n").filter(Boolean)) { + const parts = line.split("\t"); + if (!parts[0] || parts.length > 2) fail("attached-client evidence is malformed"); + addAttached(parts[0], parts[1] || "client"); + } +} + +let browserConnections = 0; +try { + let lsof; + try { + lsof = cp.execFileSync("lsof", ["-nP", `-iTCP:${process.env.ACTIVE_PORT}`, "-sTCP:ESTABLISHED"], {encoding:"utf8"}); + } catch (error) { + if (error.status !== 1 || error.stdout === undefined) throw error; + lsof = String(error.stdout); + } + browserConnections = lsof.split("\n").filter(line => /^(Google|Chromium|Chrome)\s/.test(line)).length; +} catch (error) { + addInventoryError("runtime", `cannot inspect established connections: ${error.message}`); +} +try { + const ps = cp.execFileSync("ps", ["-axo", "command="], {encoding:"utf8"}); + for (const row of Object.values(state.sessions)) { + if (isObject(row) && isString(row.file) && isString(row.key) && ps.split("\n").some(line => line.includes("lavish-axi poll") && line.includes(row.file))) addAttached(row.key, "live-poll-process"); + } +} catch (error) { + addInventoryError("runtime", `cannot inspect live poll processes: ${error.message}`); +} + +const dataOwnersFor = file => { + const owners = []; + for (const home of normalizedHomes) { + const dataRoot = path.join(home, "data"); + if (!isUnder(file, dataRoot)) continue; + const task = path.relative(dataRoot, file).split(path.sep)[0]; + if (isSlug(task)) owners.push({home,task}); + } + return owners; +}; +const ledgerRowsFor = key => ledgers.get(key) || []; +const currentOwnersFor = file => meta.filter(owner => owner.worktree && isUnder(file, owner.worktree)); +const lastActivityFor = (row, ledgerRows) => { + const values = [row.updated_at, ...ledgerRows.map(item => item.last_polled_at)].filter(isString).map(value => Date.parse(value)).filter(Number.isFinite); + return values.length ? Math.max(...values) : NaN; +}; +const homeBoardOwnersFor = file => normalizedHomes.filter(home => file === path.join(home, ".lavish", "bearings-board.html")).map(home => ({home,task:"home"})); +const validSessionRow = (row, registryKey) => { + if (!isObject(row) || !isString(row.key) || !row.key || /[\r\n]/.test(row.key) || !isString(row.file) || !path.isAbsolute(row.file) || /[\r\n]/.test(row.file)) return false; + if (registryKey !== undefined && row.key !== registryKey) return false; + if (row.url !== undefined && !isString(row.url)) return false; + if (row.updated_at !== undefined && (!isString(row.updated_at) || (row.updated_at && !Number.isFinite(Date.parse(row.updated_at))))) return false; + if (row.pending_prompts !== undefined && (!Number.isInteger(row.pending_prompts) || row.pending_prompts < 0)) return false; + for (const field of ["prompts", "pending_deliveries", "layout_warnings"]) if (row[field] !== undefined && !Array.isArray(row[field])) return false; + for (const field of ["layout_warnings_pending", "layout_warning_repair_open"]) if (row[field] !== undefined && typeof row[field] !== "boolean") return false; + return ["open", "feedback", "ended"].includes(row.status); +}; +for (const [registryKey, row] of Object.entries(state.sessions)) { + if (!validSessionRow(row, registryKey)) addInventoryError("Lavish state", "malformed Lavish registry row: " + registryKey); +} +const evidenceFor = row => { + const evidence = []; + if (!validSessionRow(row)) return {classification:"ambiguous",evidence:["malformed-registry-row"]}; + if (row.status === "ended") return {classification:"preserve", evidence:["historical-ended-registry-row"]}; + let artifactState; + try { + const stat = fs.lstatSync(row.file); + artifactState = stat.isFile() && !stat.isSymbolicLink() ? "regular" : "unsupported"; + } catch (error) { + if (error.code === "ENOENT") artifactState = "missing"; + else artifactState = "unsupported"; + } + if (artifactState === "missing") return {classification:"ambiguous", evidence:["unsupported-by-current-Lavish","artifact-missing"]}; + if (artifactState !== "regular") return {classification:"ambiguous", evidence:["unsupported-by-current-Lavish","artifact-not-regular"]}; + if ([...preservePaths].some(item => row.file === item || (item.endsWith(path.sep) && row.file.startsWith(item)))) return {classification:"preserve",evidence:["captain-preserve-path"]}; + + const sid = sourceId(row.file); + const guardTarget = Boolean(process.env.GUARD_KEY) && row.key === process.env.GUARD_KEY && row.file === process.env.GUARD_FILE; + const guardOwnerAllowed = owner => guardTarget && owner.home === process.env.GUARD_HOME && (owner.task_id || owner.task) === process.env.GUARD_TASK; + const withoutGuardOwner = owners => owners.filter(owner => !guardOwnerAllowed(owner)); + const currentOwners = withoutGuardOwner(currentOwnersFor(row.file)); + const rowLedgers = withoutGuardOwner(ledgerRowsFor(row.key)); + const dataOwners = withoutGuardOwner(dataOwnersFor(row.file)); + const openDataOwners = dataOwners.filter(owner => !closed.has(ownerKey(owner.home, owner.task))); + const boardOwners = withoutGuardOwner(homeBoardOwnersFor(row.file)); + const ownerIdentities = new Set(); + for (const owner of currentOwners) ownerIdentities.add(ownerKey(owner.home, owner.task)); + for (const owner of rowLedgers) ownerIdentities.add(ownerKey(owner.home, owner.task_id)); + for (const owner of dataOwners) ownerIdentities.add(ownerKey(owner.home, owner.task)); + for (const owner of boardOwners) ownerIdentities.add(ownerKey(owner.home, owner.task)); + const knownHomes = new Set(normalizedHomes); + const unlistedLedger = rowLedgers.some(owner => !knownHomes.has(owner.home)); + const duplicateLedger = rowLedgers.length > 1; + const ambiguousOwnership = ownerIdentities.size > 1 || duplicateLedger || unlistedLedger; + if (currentOwners.length) for (const owner of currentOwners) evidence.push(`current-task:${owner.task}`); + for (const owner of rowLedgers) { + evidence.push(`ledger-owner:${owner.task_id}`); + if (currentOwners.some(item => item.home === owner.home && item.task === owner.task_id)) evidence.push(`ledger-live-task:${owner.task_id}`); + } + for (const owner of openDataOwners) evidence.push(`data-owner:${owner.task}`); + for (const owner of boardOwners) evidence.push(`home-durable-review:${owner.home}`); + for (const owner of [...rowLedgers, ...dataOwners]) { + const key = ownerKey(owner.home, owner.task_id || owner.task); + const kinds = held.get(key) || []; + for (const kind of kinds) evidence.push(`retained-backlog-hold:${kind}`); + } + if (ambiguousOwnership) evidence.push("ambiguous-ownership"); + if (row.status === "feedback" || Number(row.pending_prompts || 0) > 0 || (row.prompts || []).length > 0) evidence.push("feedback-or-pending-prompts"); + if ((row.pending_deliveries || []).length > 0 || unacked.has(sid)) evidence.push("unacknowledged-delivery"); + if (decisions.has(sid)) evidence.push("open-decision-binding"); + if (attached.has(row.key)) for (const kind of attached.get(row.key)) evidence.push(`attached-${kind}`); + if ((row.layout_warnings || []).length > 0 || row.layout_warnings_pending || row.layout_warning_repair_open) evidence.push("unresolved-layout-warning-repair"); + const sourceHomesForRow = sourceHomes.get(sid) || []; + const sourceOwnedByGuard = guardTarget && process.env.GUARD_ALLOW_SOURCE === sid && sourceHomesForRow.length === 1 && sourceHomesForRow[0] === process.env.GUARD_HOME; + if (!guardTarget && sources.has(sid)) evidence.push("registered-process-event-source"); + if (guardTarget) { + if (browserConnections > 0) evidence.push(`unmapped-browser-connections:${browserConnections}`); + if (sources.has(sid) && !sourceOwnedByGuard) evidence.push("registered-process-event-source"); + return {classification:evidence.length ? "blocked" : "ready",evidence}; + } + if (ambiguousOwnership) return {classification:"ambiguous",evidence}; + if (evidence.some(item => !item.startsWith("retained-backlog-hold:") && !item.startsWith("current-task:") && !item.startsWith("ledger-live-task:") && !item.startsWith("ledger-owner:") && !item.startsWith("data-owner:") && !item.startsWith("home-durable-review:")) || currentOwners.length || rowLedgers.length || openDataOwners.length || boardOwners.length || [...rowLedgers, ...dataOwners].some(owner => held.has(ownerKey(owner.home, owner.task_id || owner.task)))) return {classification:"preserve",evidence}; + if (row.file.includes(`${path.sep}.treehouse${path.sep}`)) return {classification:"preserve",evidence:["retained-worktree-file"]}; + + if (unreadableHome) return {classification:"ambiguous",evidence:["ownership-incomplete-unreadable-home"]}; + const lastActivity = lastActivityFor(row, rowLedgers); + const expired = Number.isFinite(lastActivity) && Date.now() - lastActivity >= expiryHours * 60 * 60 * 1000; + if (expired && browserConnections > 0) return {classification:"ambiguous",evidence:[`idle-expired:${expiryHours}h`,`unmapped-browser-connections:${browserConnections}`]}; + + const closedOwners = []; + for (const owner of [...rowLedgers, ...dataOwners]) { + const key = ownerKey(owner.home, owner.task_id || owner.task); + if (closed.has(key) && !closedOwners.some(item => item === key)) closedOwners.push(key); + } + if (closedOwners.length > 1) return {classification:"ambiguous",evidence:["ambiguous-closed-task-ownership",...closedOwners.map(item => `closed-task:${item.replace("\0",":")}`)]}; + if (browserConnections > 0) return {classification:"ambiguous",evidence:[`unmapped-browser-connections:${browserConnections}`]}; + if (expired) return {classification:"eligible",evidence:[`idle-expired:${expiryHours}h`,"existing-artifact","no-review-owner-or-client"]}; + if (closedOwners.length === 1) return {classification:"eligible",evidence:[`closed-task:${closedOwners[0].replace("\0",":")}`,"existing-artifact","no-review-owner-or-client"]}; + return {classification:"ambiguous",evidence:["no-positive-closed-task-owner"]}; +}; + +const rows = Object.values(state.sessions).sort((a,b) => String(a?.key || "").localeCompare(String(b?.key || ""))); +const counts = {total:rows.length,open:0,feedback:0,ended:0,missing_file:0,past_expiry:0,with_live_task:0,without_live_task:0,active_poll_registrations:activePollRegistrations,attached_clients:attached.size,unmapped_browser_connections:browserConnections}; +const eligible = []; +for (const row of rows) { + if (isObject(row) && ["open","feedback","ended"].includes(row.status)) counts[row.status]++; + if (!isObject(row) || !isString(row.file) || !regularArtifact(row.file)) counts.missing_file++; + const rowLedgers = isObject(row) && isString(row.key) ? ledgerRowsFor(row.key) : []; + const lastActivity = isObject(row) ? lastActivityFor(row, rowLedgers) : NaN; + if (row?.status === "open" && Number.isFinite(lastActivity) && Date.now() - lastActivity >= expiryHours * 60 * 60 * 1000) counts.past_expiry++; + const live = isObject(row) && isString(row.file) && currentOwnersFor(row.file).length > 0; + if (row?.status !== "ended") counts[live ? "with_live_task" : "without_live_task"]++; + const verdict = evidenceFor(row); + if (verdict.classification === "eligible") eligible.push({key:row.key,file:row.file,url:row.url || "",status:row.status,updated_at:row.updated_at || "",evidence:verdict.evidence}); + if (process.env.AUDIT_MODE === "audit") process.stdout.write(`${verdict.classification}\t${row?.key || ""}\t${verdict.evidence.join(",")}\t${row?.file || ""}\n`); +} +if (unreadableHome) fail(`home inventory is unreadable or malformed; refusing eligibility: ${inventoryErrors.join("; ")}`); +if (process.env.AUDIT_MODE === "guard") { + const target = rows.find(row => isObject(row) && row.key === process.env.GUARD_KEY && row.file === process.env.GUARD_FILE); + if (!target) fail("durable Lavish guard target is absent from the registry"); + const verdict = evidenceFor(target); + if (verdict.classification !== "ready") fail("durable Lavish end guard refused: " + verdict.evidence.join(",")); + process.stdout.write("durable Lavish end guard: ready\n"); +} +if (process.env.AUDIT_MODE === "summary") { + process.stdout.write(`Lavish registry rows: total=${counts.total} open=${counts.open} feedback=${counts.feedback} ended=${counts.ended} missing_file=${counts.missing_file} past_expiry=${counts.past_expiry} expiry_hours=${expiryHours} with_live_task=${counts.with_live_task} without_live_task=${counts.without_live_task}; live connections: attached_clients=${counts.attached_clients} unmapped_browser_connections=${counts.unmapped_browser_connections}; active_poll_registrations=${counts.active_poll_registrations}\n`); +} +if (process.env.FREEZE_FILE) { + const dir = path.dirname(process.env.FREEZE_FILE); + try { + fs.mkdirSync(dir,{recursive:true,mode:0o700}); + const temp = path.join(dir, `.${path.basename(process.env.FREEZE_FILE)}.${process.pid}`); + fs.writeFileSync(temp, eligible.map(row => JSON.stringify(row)).join("\n") + (eligible.length ? "\n" : ""), {mode:0o600}); + fs.renameSync(temp,process.env.FREEZE_FILE); + } catch (error) { fail(`cannot publish frozen candidate: ${error.message}`); } +} +NODE +} + +cmd_audit() { + local freeze='' homes expiry=48 preserve_paths='' + while [ "$#" -gt 0 ]; do + case "$1" in + --freeze) [ "$#" -ge 2 ] || usage; freeze=$2; shift 2 ;; + --expiry-hours) [ "$#" -ge 2 ] || usage; expiry=$2; shift 2 ;; + --preserve-paths) [ "$#" -ge 2 ] || usage; preserve_paths=$2; shift 2 ;; + *) usage ;; + esac + done + homes=$(mktemp "${TMPDIR:-/tmp}/fm-lavish-homes.XXXXXX") || die "cannot stage home inventory" + # shellcheck disable=SC2064 # Expand the function-local path while it is in scope. + trap "rm -f -- '$homes'" EXIT + make_homes_file "$homes" + FM_LAVISH_IDLE_EXPIRY_HOURS="$expiry" FM_LAVISH_PRESERVE_PATHS_FILE="$preserve_paths" \ + run_audit_node audit "$homes" "$freeze" +} + +cmd_summary() { + local homes + [ "$#" -eq 0 ] || usage + homes=$(mktemp "${TMPDIR:-/tmp}/fm-lavish-homes.XXXXXX") || die "cannot stage home inventory" + # shellcheck disable=SC2064 # Expand the function-local path while it is in scope. + trap "rm -f -- '$homes'" EXIT + make_homes_file "$homes" + run_audit_node summary "$homes" +} + +canonical_file() { + perl -MCwd=realpath -e '$p = realpath($ARGV[0]); defined($p) or exit 1; print "$p\n"' "$1" 2>/dev/null \ + || die "cannot resolve path: $1" +} + +cmd_guard() { + [ "$#" -ge 3 ] || usage + local task=$1 artifact=$2 key=$3 allow_source='' real homes guard_home + shift 3 + while [ "$#" -gt 0 ]; do + case "$1" in + --allow-source) [ "$#" -ge 2 ] || usage; allow_source=$2; shift 2 ;; + *) usage ;; + esac + done + case "$task" in ''|*[!A-Za-z0-9._-]*) die "task id must be a privacy-safe slug: $task" ;; esac + case "$key" in ''|*$'\n'*|*$'\r'*) die "Lavish key is invalid" ;; esac + real=$(canonical_file "$artifact") + [ -f "$real" ] && [ ! -L "$real" ] || die "artifact is not a safe regular file: $artifact" + guard_home=$(canonical_file "$FM_HOME") + homes=$(mktemp "${TMPDIR:-/tmp}/fm-lavish-homes.XXXXXX") || die "cannot stage home inventory" + # shellcheck disable=SC2064 + trap "rm -f -- '$homes'" EXIT + make_homes_file "$homes" + run_audit_node guard "$homes" '' "$task" "$real" "$key" "$guard_home" "$allow_source" >/dev/null \ + || die "durable Lavish end guard refused: $key" +} + +count_registry() { + # shellcheck disable=SC2016 # The single-quoted program is JavaScript, not shell. + LAVISH_STATE_FILE="$LAVISH_STATE_FILE" node -e ' + const fs=require("node:fs"); const s=JSON.parse(fs.readFileSync(process.env.LAVISH_STATE_FILE,"utf8")); + const c={open:0,feedback:0,ended:0}; for(const r of Object.values(s.sessions||{})) if(c[r.status]!==undefined)c[r.status]++; + process.stdout.write(`open=${c.open} feedback=${c.feedback} ended=${c.ended}`);' +} + +build_apply_queue() { + local candidate=$1 authority=${2-} queue=$3 + CANDIDATE_FILE="$candidate" AUTHORITY_FILE="$authority" AUTHORIZATION_RULING="$AUTHORIZATION_RULING" node <<'NODE' > "$queue" \ + || return 1 +const fs = require("node:fs"); +const fail = message => { console.error(`error: ${message}`); process.exit(1); }; +const isObject = value => value && typeof value === "object" && !Array.isArray(value); +const isString = value => typeof value === "string"; +const requiredRow = (row, label) => { + if (!isObject(row) || !isString(row.key) || !row.key || !isString(row.file) || !row.file || !isString(row.status) || row.status !== "open" || (row.updated_at !== undefined && !isString(row.updated_at)) || (row.url !== undefined && !isString(row.url))) fail(`${label} contains an unsupported row`); + return {key:row.key,file:row.file,url:row.url || "",status:row.status,updated_at:row.updated_at || ""}; +}; +const readJsonLines = (file, label) => { + const rows = []; + for (const line of fs.readFileSync(file, "utf8").split("\n").filter(Boolean)) { + let row; + try { row = JSON.parse(line); } catch { fail(`${label} contains malformed JSON`); } + rows.push(requiredRow(row,label)); + } + return rows; +}; +const candidate = readJsonLines(process.env.CANDIDATE_FILE, "candidate file"); +let authority = null; +if (process.env.AUTHORITY_FILE) { + try { authority = JSON.parse(fs.readFileSync(process.env.AUTHORITY_FILE, "utf8")); } + catch (error) { fail(`cannot read authorization file: ${error.message}`); } + if (!isObject(authority) || authority.schema !== "fm-lavish-session-authority.v1" || authority.ruling_date !== "2026-09-08" || authority.frozen_at !== "2026-09-08" || authority.ruling !== process.env.AUTHORIZATION_RULING || !Array.isArray(authority.authorized) || !Array.isArray(authority.excluded) || authority.excluded.length !== 3) fail("authorization file does not carry the frozen 2026-09-08 ruling and three exclusions"); + const keptBoards = new Map([ + ["7f59a8c16dff9f19", {url:"http://127.0.0.1:4387/session/7f59a8c16dff9f19",file:"/Users/ivan/Projects/firstmate/data/nancy-tennis-directions-board-b2/board/index.html"}], + ["4ae99e8ad06d4a8c", {url:"http://127.0.0.1:4387/session/4ae99e8ad06d4a8c",file:"/Users/ivan/.treehouse/firstmate-bd0d1d/8/firstmate/data/ally-screener-paid-media/board/index.html"}], + ["cc73671c247bff78", {url:"http://127.0.0.1:4387/session/cc73671c247bff78",file:"/Users/ivan/Projects/firstmate/data/syd-board-b1/board/index.html"}], + ]); + const exclusions = new Set(); + for (const row of authority.excluded) { + const expected = isObject(row) && keptBoards.get(row.key); + if (!expected || !isString(row.file) || !isString(row.url) || row.file !== expected.file || row.url !== expected.url || row.reason !== "kept board named in the 2026-09-08 ruling" || exclusions.has(row.key)) fail("authorization exclusions are malformed, duplicated, or do not match the three kept boards"); + exclusions.add(row.key); + } + if (exclusions.size !== keptBoards.size) fail("authorization exclusions do not cover the three kept boards"); + const keys = new Set(); + for (const row of authority.authorized) { + const normalized = requiredRow(row,"authorization file"); + if (row.classification !== "ambiguous" || keys.has(normalized.key) || exclusions.has(normalized.key) || authority.excluded.some(item => item.file === normalized.file || item.url === normalized.url)) fail("authorization rows are malformed, duplicated, or excluded"); + keys.add(normalized.key); + process.stdout.write(`${JSON.stringify({...normalized,authorization:"captain-authorized"})}\n`); + } + for (const row of candidate) if (exclusions.has(row.key) || authority.excluded.some(item => item.file === row.file || item.url === row.url)) fail(`candidate is one of the three kept boards: ${row.key}`); +} +const seen = new Set(); +for (const row of candidate) { + if (["7f59a8c16dff9f19", "4ae99e8ad06d4a8c", "cc73671c247bff78"].includes(row.key)) fail("candidate is one of the three kept boards: " + row.key); +} +for (const row of candidate) { + if (seen.has(row.key)) fail(`candidate contains duplicate key: ${row.key}`); + seen.add(row.key); + process.stdout.write(`${JSON.stringify({...row,authorization:"eligible-candidate"})}\n`); +} +if (authority) { + const authorizedRows = authority.authorized.map(row => requiredRow(row,"authorization file")); + for (const row of authorizedRows) { + if (seen.has(row.key)) fail(`candidate and authorization both contain key: ${row.key}`); + seen.add(row.key); + } +} +NODE +} + +finalize_key_for_homes() { + local key=$1 homes_file=$2 home + while IFS= read -r home || [ -n "$home" ]; do + [ -n "$home" ] || continue + [ -d "$home/state" ] || continue + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + "$SCRIPT_DIR/fm-lavish-session.sh" finalize-key "$key" >/dev/null \ + || die "cannot finalize Lavish ledger rows for $key" + done < "$homes_file" +} + +cmd_apply() { + local candidate=${1-} batch=10 processed=0 line key file expected_status expected_updated expected_url current verdict authorization homes audit_output expiry=48 preserve_paths='' authority='' queue + [ -n "$candidate" ] || usage + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --authorized) + if [ "$#" -ge 2 ] && [ "${2#--}" = "$2" ]; then authority=$2; shift 2; else authority=$AUTHORIZATION_DEFAULT; shift; fi + ;; + --batch-size) [ "$#" -ge 2 ] || usage; batch=$2; shift 2 ;; + --expiry-hours) [ "$#" -ge 2 ] || usage; expiry=$2; shift 2 ;; + --preserve-paths) [ "$#" -ge 2 ] || usage; preserve_paths=$2; shift 2 ;; + *) usage ;; + esac + done + case "$batch" in ''|*[!0-9]*) die "batch size must be from 1 to 50" ;; esac + [ "$batch" -ge 1 ] && [ "$batch" -le 50 ] || die "batch size must be from 1 to 50" + [ -f "$candidate" ] && [ ! -L "$candidate" ] || die "candidate file is not a regular file: $candidate" + if [ -n "$authority" ]; then + [ -f "$authority" ] && [ ! -L "$authority" ] || die "authorization file is not a regular file: $authority" + fi + homes=$(mktemp "${TMPDIR:-/tmp}/fm-lavish-homes.XXXXXX") || die "cannot stage home inventory" + queue=$(mktemp "${TMPDIR:-/tmp}/fm-lavish-apply.XXXXXX") || { rm -f "$homes"; die "cannot stage apply queue"; } + # shellcheck disable=SC2064 # Expand the function-local paths while they are in scope. + trap "rm -f -- '$homes' '$queue'" EXIT + make_homes_file "$homes" + build_apply_queue "$candidate" "$authority" "$queue" \ + || die "cannot validate frozen apply inputs" + while IFS= read -r line; do + [ -n "$line" ] || continue + key=''; file=''; expected_status=''; expected_updated=''; expected_url=''; authorization='' + IFS=$'\t' read -r key file expected_url expected_status expected_updated authorization </dev/null 2>&1 || die "lavish-axi is not installed" + lavish_cli end "$file" >/dev/null || die "lavish-axi could not end candidate $key" + current=$(KEY="$key" LAVISH_STATE_FILE="$LAVISH_STATE_FILE" node -e 'const fs=require("node:fs");const s=JSON.parse(fs.readFileSync(process.env.LAVISH_STATE_FILE,"utf8"));process.stdout.write((s.sessions||{})[process.env.KEY]?.status||"missing")') \ + || die "cannot verify candidate after end: $key" + [ "$current" = ended ] || die "candidate did not transition to ended: $key (status=$current)" + finalize_key_for_homes "$key" "$homes" + processed=$((processed + 1)) + if [ $((processed % batch)) -eq 0 ]; then printf 'batch-complete: processed=%s %s\n' "$processed" "$(count_registry)"; fi + done < "$queue" + if [ $((processed % batch)) -ne 0 ] || [ "$processed" -eq 0 ]; then printf 'batch-complete: processed=%s %s\n' "$processed" "$(count_registry)"; fi +} + +case "${1:-audit}" in + audit) [ "$#" -eq 0 ] || shift; cmd_audit "$@" ;; + summary) shift; cmd_summary "$@" ;; + guard) shift; cmd_guard "$@" ;; + apply) shift; cmd_apply "$@" ;; + -h|--help|help) usage ;; + *) usage ;; +esac diff --git a/bin/fm-lavish-lib.sh b/bin/fm-lavish-lib.sh new file mode 100644 index 00000000000..1c901ed39e0 --- /dev/null +++ b/bin/fm-lavish-lib.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +fm_lavish_state_dir() { + [ "$#" -eq 1 ] || return 1 + case "$1" in *$'\n'*|*$'\r'*) return 1 ;; esac + case "$1" in + /*/state.json) printf '%s\n' "$(dirname "$1")" ;; + *) return 1 ;; + esac +} diff --git a/bin/fm-lavish-session.sh b/bin/fm-lavish-session.sh new file mode 100755 index 00000000000..62de3699c05 --- /dev/null +++ b/bin/fm-lavish-session.sh @@ -0,0 +1,669 @@ +#!/usr/bin/env bash +# Own Firstmate's private task-to-Lavish session ledger and supported end path. +# +# Ledger: state/.lavish-sessions, newline-delimited JSON, mode 0600. +# One current row per Lavish key binds task_id, home, canonical artifact path, +# key, URL, creation time, disposition (ephemeral-worktree or durable-review), +# and the verified ended_at time when Firstmate ended it. +# +# Usage: +# fm-lavish-session.sh register +# fm-lavish-session.sh register-auto [] +# fm-lavish-session.sh safe-park +# fm-lavish-session.sh end +# fm-lavish-session.sh end-with-source +# fm-lavish-session.sh preflight-end +# fm-lavish-session.sh end-ephemeral +# fm-lavish-session.sh poll-activity [] +# fm-lavish-session.sh finalize-key +# fm-lavish-session.sh remove-ledger +# +# register reads the installed Lavish state after the session has been served; +# it never invents a key or URL from a path. +# end and end-ephemeral use `lavish-axi end ` and then verify the +# same key changed to `ended` in Lavish's state before recording ended_at. +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}" +LAVISH_STATE_FILE="${FM_LAVISH_STATE_FILE:-${LAVISH_AXI_STATE_DIR:-$HOME/.lavish-axi}/state.json}" + +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-lavish-lib.sh +. "$SCRIPT_DIR/fm-lavish-lib.sh" + +die() { printf 'error: %s\n' "$1" >&2; exit 1; } +LAVISH_STATE_DIR=$(fm_lavish_state_dir "$LAVISH_STATE_FILE") \ + || die "FM_LAVISH_STATE_FILE must be an absolute Lavish state.json path" +usage() { sed -n '2,/^set -u$/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; exit 2; } + +validate_task_id() { + case "$1" in ''|*[!A-Za-z0-9._-]*) die "task id must be a privacy-safe slug: $1" ;; esac +} + +canonical_file() { + perl -MCwd=realpath -e '$p = realpath($ARGV[0]); defined($p) or exit 1; print "$p\n"' "$1" 2>/dev/null \ + || die "cannot resolve the artifact path: $1" +} + +ledger_path() { printf '%s/%s.lavish-sessions\n' "$STATE" "$1"; } +ledger_lock_path() { printf '%s/.%s.lavish-sessions.lock\n' "$STATE" "$1"; } +lavish_cli() { LAVISH_AXI_STATE_DIR="$LAVISH_STATE_DIR" command lavish-axi "$@"; } + +ledger_is_safe() { + local ledger=$1 + if [ -e "$ledger" ] || [ -L "$ledger" ]; then + [ -f "$ledger" ] && [ ! -L "$ledger" ] || return 1 + fi + return 0 +} + +session_json_for_file() { + ARTIFACT_REAL="$1" LAVISH_STATE_FILE="$LAVISH_STATE_FILE" node <<'NODE' +const fs = require("node:fs"); +let state; +try { state = JSON.parse(fs.readFileSync(process.env.LAVISH_STATE_FILE, "utf8")); } +catch (error) { console.error(`error: cannot read Lavish state: ${error.message}`); process.exit(1); } +const matches = Object.values(state.sessions || {}).filter(row => row && row.file === process.env.ARTIFACT_REAL); +if (matches.length !== 1) { + console.error(`error: expected one Lavish session for ${process.env.ARTIFACT_REAL}, found ${matches.length}`); + process.exit(1); +} +process.stdout.write(JSON.stringify(matches[0])); +NODE +} + +write_registration() { + local task=$1 real=$2 disposition=$3 session_json=$4 ledger tmp lock home_real + ledger=$(ledger_path "$task") + lock=$(ledger_lock_path "$task") + home_real=$(canonical_file "$FM_HOME") + mkdir -p "$STATE" || die "cannot create state directory: $STATE" + ledger_is_safe "$ledger" || die "Lavish ledger is not a safe regular file: $ledger" + fm_lock_acquire_wait "$lock" || die "cannot lock the Lavish ledger for $task" + tmp=$(umask 077; mktemp "$STATE/.${task}.lavish-sessions.XXXXXX") \ + || { fm_lock_release "$lock"; die "cannot stage the Lavish ledger"; } + TASK_ID="$task" HOME_REAL="$home_real" ARTIFACT_REAL="$real" \ + DISPOSITION="$disposition" SESSION_JSON="$session_json" LEDGER="$ledger" node <<'NODE' > "$tmp" \ + || { rm -f "$tmp"; fm_lock_release "$lock"; die "cannot update the Lavish ledger"; } +const fs = require("node:fs"); +const session = JSON.parse(process.env.SESSION_JSON); +if (!session.key || !session.url || !["open", "feedback"].includes(session.status)) { + console.error("served Lavish session is missing an open key or URL"); + process.exit(1); +} +const rows = []; +if (fs.existsSync(process.env.LEDGER)) { + for (const line of fs.readFileSync(process.env.LEDGER, "utf8").split("\n")) { + if (!line) continue; + let row; + try { row = JSON.parse(line); } catch { console.error("existing Lavish ledger is malformed"); process.exit(1); } + if (!row || typeof row !== "object" || Array.isArray(row) || typeof row.key !== "string" || typeof row.artifact !== "string" || typeof row.task_id !== "string" || typeof row.home !== "string" || !["ephemeral-worktree", "durable-review"].includes(row.disposition)) { + console.error("existing Lavish ledger has an invalid row"); + process.exit(1); + } + rows.push(row); + } +} +const prior = rows.find(row => row.key === session.key); +const row = { + task_id: process.env.TASK_ID, + home: process.env.HOME_REAL, + artifact: process.env.ARTIFACT_REAL, + key: session.key, + url: session.url, + created_at: prior?.created_at || new Date().toISOString(), + last_polled_at: new Date().toISOString(), + disposition: process.env.DISPOSITION, +}; +const next = rows.filter(item => item.key !== session.key); +next.push(row); +for (const item of next) process.stdout.write(`${JSON.stringify(item)}\n`); +NODE + chmod 0600 "$tmp" || { rm -f "$tmp"; fm_lock_release "$lock"; die "cannot protect the Lavish ledger"; } + mv -f "$tmp" "$ledger" || { rm -f "$tmp"; fm_lock_release "$lock"; die "cannot publish the Lavish ledger"; } + fm_lock_release "$lock" || die "cannot release the Lavish ledger lock" +} + +cmd_register() { + local task=${1-} artifact=${2-} disposition=${3-} real session_json + [ "$#" -eq 3 ] || usage + validate_task_id "$task" + case "$disposition" in ephemeral-worktree|durable-review) ;; *) die "invalid disposition: $disposition" ;; esac + [ -f "$artifact" ] && [ ! -L "$artifact" ] || die "artifact is not a regular file: $artifact" + real=$(canonical_file "$artifact") + session_json=$(session_json_for_file "$real") || exit 1 + write_registration "$task" "$real" "$disposition" "$session_json" + printf 'registered: %s %s\n' "$task" "$real" +} + +resolve_owner() { + local artifact=$1 explicit=${2-} real home_real meta task worktree relative owner='' disposition='' found_explicit=0 matches owners_text + local -a owners=() dispositions=() + real=$(canonical_file "$artifact") + home_real=$(canonical_file "$FM_HOME") + if [ -n "$explicit" ]; then + validate_task_id "$explicit" + fi + if [ -e "$STATE" ] || [ -L "$STATE" ]; then + [ -d "$STATE" ] && [ ! -L "$STATE" ] || die "task state is not a safe directory: $STATE" + [ -r "$STATE" ] || die "task state is unreadable: $STATE" + fi + for meta in "$STATE"/*.meta; do + [ -e "$meta" ] || [ -L "$meta" ] || continue + [ -f "$meta" ] && [ ! -L "$meta" ] || die "task metadata is not a safe regular file: $meta" + task=${meta##*/}; task=${task%.meta} + validate_task_id "$task" + worktree=$(awk '/^worktree=/{value=substr($0,10)} END{if (value != "") print value}' "$meta") || die "cannot read task metadata: $meta" + case "$worktree" in + ''|/*) ;; + *) die "task metadata has an unsafe worktree path: $meta" ;; + esac + if [ -d "$worktree" ]; then + worktree=$(canonical_file "$worktree") + fi + if [ -n "$worktree" ] && { [ "$real" = "$worktree" ] || [[ "$real" == "$worktree"/* ]]; }; then + owners+=("$task"); dispositions+=(ephemeral-worktree) + fi + done + + case "$real" in + "$home_real/data"/*) + relative=${real#"$home_real/data/"} + owner=${relative%%/*} + validate_task_id "$owner" + owners+=("$owner"); dispositions+=(durable-review) + ;; + "$home_real/.lavish/bearings-board.html") + owners+=(home); dispositions+=(durable-review) + ;; + esac + + matches=${#owners[@]} + if [ -n "$explicit" ]; then + for task in "${owners[@]}"; do + [ "$task" = "$explicit" ] || continue + found_explicit=1 + break + done + fi + [ "$matches" -gt 0 ] || die "cannot establish a task owner for Lavish artifact: $real" + [ -z "$explicit" ] || [ "$found_explicit" -eq 1 ] || die "artifact is not owned by task $explicit: $real" + if [ "$matches" -ne 1 ]; then + owners_text=$(IFS=,; printf '%s' "${owners[*]}") + die "Lavish artifact ownership is ambiguous for $real: $owners_text" + fi + owner=${owners[0]} + disposition=${dispositions[0]} + [ -z "$explicit" ] || [ "$owner" = "$explicit" ] || die "artifact ownership resolved to $owner, not $explicit: $real" + printf '%s\t%s\n' "$owner" "$disposition" +} + +cmd_register_auto() { + local artifact=${1-} explicit=${2-} owner disposition resolved + [ "$#" -ge 1 ] && [ "$#" -le 2 ] || usage + resolved=$(resolve_owner "$artifact" "$explicit") || exit 1 + owner=${resolved%%$'\t'*} + disposition=${resolved#*$'\t'} + cmd_register "$owner" "$artifact" "$disposition" +} + +mark_ended() { + local task=$1 key=$2 ledger tmp lock + ledger=$(ledger_path "$task") + lock=$(ledger_lock_path "$task") + [ -f "$ledger" ] && [ ! -L "$ledger" ] || return 1 + fm_lock_acquire_wait "$lock" || return 1 + tmp=$(umask 077; mktemp "$STATE/.${task}.lavish-sessions.XXXXXX") \ + || { fm_lock_release "$lock"; return 1; } + KEY="$key" LEDGER="$ledger" node <<'NODE' > "$tmp" \ + || { rm -f "$tmp"; fm_lock_release "$lock"; return 1; } +const fs = require("node:fs"); +const rows = fs.readFileSync(process.env.LEDGER, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line)); +let found = false; +for (const row of rows) { + if (row.key === process.env.KEY) { row.ended_at = new Date().toISOString(); found = true; } + process.stdout.write(`${JSON.stringify(row)}\n`); +} +if (!found) process.exit(1); +NODE + if ! chmod 0600 "$tmp" || ! mv -f "$tmp" "$ledger"; then + rm -f "$tmp" + fm_lock_release "$lock" + return 1 + fi + fm_lock_release "$lock" || return 1 +} + +ledger_key_is_ended() { + local task=$1 key=$2 ledger lock status rc + ledger=$(ledger_path "$task") + ledger_is_safe "$ledger" || return 1 + [ -f "$ledger" ] || return 1 + lock=$(ledger_lock_path "$task") + fm_lock_acquire_wait "$lock" || return 1 + status=$(KEY="$key" LEDGER="$ledger" node <<'NODE' +const fs = require("node:fs"); +const rows = fs.readFileSync(process.env.LEDGER, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line)); +const row = rows.find(item => item.key === process.env.KEY); +process.stdout.write(row ? (row.ended_at ? "ended" : "active") : "missing"); +NODE + ) + rc=$? + fm_lock_release "$lock" || return 1 + [ "$rc" -eq 0 ] && [ "$status" = ended ] +} + +canonical_existing_prefix() { + local path=$1 suffix='' component parent real + while [ ! -e "$path" ] && [ ! -L "$path" ]; do + component=${path##*/} + [ -n "$component" ] || return 1 + suffix="/$component$suffix" + parent=${path%/*} + [ "$parent" != "$path" ] || return 1 + path=$parent + done + [ -d "$path" ] && [ ! -L "$path" ] || return 1 + real=$(canonical_file "$path") || return 1 + printf '%s%s\n' "$real" "$suffix" +} + +end_recorded_file() { + local task=$1 real=$2 key=$3 status + [ -f "$real" ] && [ ! -L "$real" ] || die "cannot end missing Lavish artifact through supported CLI semantics: $real" + command -v lavish-axi >/dev/null 2>&1 || die "lavish-axi is not installed" + lavish_cli end "$real" >/dev/null || die "lavish-axi could not end $real" + status=$(KEY="$key" LAVISH_STATE_FILE="$LAVISH_STATE_FILE" node <<'NODE' +const fs = require("node:fs"); +const state = JSON.parse(fs.readFileSync(process.env.LAVISH_STATE_FILE, "utf8")); +const row = (state.sessions || {})[process.env.KEY]; +process.stdout.write(row?.status || "missing"); +NODE + ) || die "cannot verify Lavish state after ending $real" + [ "$status" = ended ] || die "Lavish key $key did not transition to ended (status=$status)" + if ! mark_ended "$task" "$key"; then + finalize_key_in_ledger "$task" "$key" \ + || die "Lavish session ended but ledger finalization is pending: $key" + fi + ledger_key_is_ended "$task" "$key" \ + || die "Lavish session ended but ledger finalization could not be verified: $key" + printf 'ended: %s %s\n' "$key" "$real" +} + +ledger_rows_unlocked() { + local task=$1 disposition=${2-} ledger + ledger=$(ledger_path "$task") + ledger_is_safe "$ledger" || return 1 + [ -f "$ledger" ] || return 0 + TASK_ID="$task" DISPOSITION="$disposition" LEDGER="$ledger" node <<'NODE' +const fs = require("node:fs"); +for (const line of fs.readFileSync(process.env.LEDGER, "utf8").split("\n")) { + if (!line) continue; + const row = JSON.parse(line); + if (!row || typeof row !== "object" || Array.isArray(row) || typeof row.task_id !== "string" || row.task_id !== process.env.TASK_ID || typeof row.home !== "string" || typeof row.artifact !== "string" || typeof row.key !== "string" || !["ephemeral-worktree", "durable-review"].includes(row.disposition) || (row.ended_at !== undefined && typeof row.ended_at !== "string")) { + console.error("Lavish ledger has an invalid row"); + process.exit(1); + } + if (row.ended_at) continue; + if (process.env.DISPOSITION && row.disposition !== process.env.DISPOSITION) continue; + process.stdout.write(`${row.artifact}\t${row.key}\t${row.disposition}\n`); + } +NODE +} + +ledger_rows() { + local task=$1 disposition=${2-} lock ledger rows rc + lock=$(ledger_lock_path "$task") + ledger=$(ledger_path "$task") + ledger_is_safe "$ledger" || return 1 + [ -f "$ledger" ] || return 0 + fm_lock_acquire_wait "$lock" || return $? + rows=$(ledger_rows_unlocked "$task" "$disposition") || { + rc=$? + fm_lock_release "$lock" + return "$rc" + } + fm_lock_release "$lock" || return $? + printf '%s\n' "$rows" +} + +touch_ledger_poll() { + local task=$1 real=$2 ledger lock tmp rc + ledger=$(ledger_path "$task") + ledger_is_safe "$ledger" || return 1 + [ -f "$ledger" ] || return 2 + lock=$(ledger_lock_path "$task") + fm_lock_acquire_wait "$lock" || return $? + tmp=$(umask 077; mktemp "$STATE/.${task}.lavish-sessions.XXXXXX") || { + fm_lock_release "$lock" + return 1 + } + ARTIFACT_REAL="$real" LEDGER="$ledger" node <<'NODE' > "$tmp" +const fs = require("node:fs"); +const rows = fs.readFileSync(process.env.LEDGER, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line)); +const matches = rows.filter(row => !row.ended_at && row.artifact === process.env.ARTIFACT_REAL); +if (matches.length !== 1) process.exit(2); +matches[0].last_polled_at = new Date().toISOString(); +for (const row of rows) process.stdout.write(`${JSON.stringify(row)}\n`); +NODE + rc=$? + if [ "$rc" -eq 2 ]; then + rm -f "$tmp" + fm_lock_release "$lock" + return 2 + fi + if [ "$rc" -ne 0 ] || ! chmod 0600 "$tmp" || ! mv -f "$tmp" "$ledger"; then + rm -f "$tmp" + fm_lock_release "$lock" + return 1 + fi + fm_lock_release "$lock" || return 1 +} + +cmd_poll_activity() { + local artifact=${1-} task=${2-} real ledger name + local matched=0 rc + [ "$#" -ge 1 ] && [ "$#" -le 2 ] || usage + [ -f "$artifact" ] && [ ! -L "$artifact" ] || die "cannot record activity for missing Lavish artifact: $artifact" + real=$(canonical_file "$artifact") + if [ -n "$task" ]; then + validate_task_id "$task" + touch_ledger_poll "$task" "$real" || { + rc=$? + [ "$rc" -eq 2 ] && die "Lavish poll has no active ownership ledger row for $real" + die "cannot refresh the Lavish poll activity ledger" + } + return 0 + fi + for ledger in "$STATE"/*.lavish-sessions; do + [ -e "$ledger" ] || [ -L "$ledger" ] || continue + ledger_is_safe "$ledger" || die "Lavish ledger is not a safe regular file: $ledger" + name=${ledger##*/}; name=${name%.lavish-sessions} + validate_task_id "$name" + touch_ledger_poll "$name" "$real" || { + rc=$? + [ "$rc" -eq 2 ] && continue + die "cannot refresh the Lavish poll activity ledger" + } + matched=$((matched + 1)) + done + [ "$matched" -gt 0 ] || die "Lavish poll has no active ownership ledger row for $real" +} + +guard_durable_end() { + local task=$1 real=$2 key=$3 allow_source=${4-} source_id result hold_status=0 session_json source_path origin_path handled_path data_override + source_id=$("$SCRIPT_DIR/fm-procevent-lavish.sh" source-id "$real") || return 1 + source_path="$STATE/procevent/$source_id.source" + if [ -e "$source_path" ] || [ -L "$source_path" ]; then + [ -f "$source_path" ] && [ ! -L "$source_path" ] \ + || die "durable Lavish review has an unsafe process-event source: $source_id" + [ "$source_id" = "$allow_source" ] \ + || die "durable Lavish review still has a registered process-event source: $source_id" + fi + origin_path="$STATE/decision-bindings/$source_id.origin" + if [ -e "$origin_path" ] || [ -L "$origin_path" ]; then + [ -f "$origin_path" ] && [ ! -L "$origin_path" ] \ + || die "durable Lavish review has an unsafe decision binding: $source_id" + die "durable Lavish review still has an open decision binding: $source_id" + fi + for result in "$STATE/procevent-inbox/$source_id".*.result; do + [ -e "$result" ] || [ -L "$result" ] || continue + [ -f "$result" ] && [ ! -L "$result" ] \ + || die "durable Lavish review has an unsafe delivery result: $source_id" + handled_path="${result%.result}.handled" + if [ -e "$handled_path" ] || [ -L "$handled_path" ]; then + [ -f "$handled_path" ] && [ ! -L "$handled_path" ] \ + || die "durable Lavish review has an unsafe delivery marker: $source_id" + else + die "durable Lavish review still has an unacknowledged delivery: $source_id" + fi + done + session_json=$(session_json_for_file "$real") || return 1 + SESSION_JSON="$session_json" KEY="$key" node <<'NODE' \ + || die "durable Lavish review still has feedback, prompts, or unresolved layout warnings: $key" +const row = JSON.parse(process.env.SESSION_JSON); +if (row.key !== process.env.KEY || row.status !== "open") process.exit(1); +if (row.pending_prompts !== undefined && (!Number.isInteger(row.pending_prompts) || row.pending_prompts < 0)) process.exit(1); +if (row.prompts !== undefined && (!Array.isArray(row.prompts) || row.prompts.length > 0)) process.exit(1); +if (row.pending_deliveries !== undefined && (!Array.isArray(row.pending_deliveries) || row.pending_deliveries.length > 0)) process.exit(1); +if (row.layout_warnings !== undefined && (!Array.isArray(row.layout_warnings) || row.layout_warnings.length > 0)) process.exit(1); +if (row.layout_warnings_pending !== undefined && typeof row.layout_warnings_pending !== "boolean") process.exit(1); +if (row.layout_warning_repair_open !== undefined && typeof row.layout_warning_repair_open !== "boolean") process.exit(1); +if (row.layout_warnings_pending === true || row.layout_warning_repair_open === true) process.exit(1); +NODE + data_override="${FM_DATA_OVERRIDE-$FM_HOME/data}" + FM_HOME="$FM_HOME" FM_DATA_OVERRIDE="$data_override" \ + "$SCRIPT_DIR/fm-captain-hold.sh" open "$task" >/dev/null 2>&1 || hold_status=$? + case "$hold_status" in + 0) die "durable Lavish review belongs to a task still held for the captain: $task" ;; + 1) ;; + *) die "cannot determine whether task $task is still held for the captain" ;; + esac + local -a audit_args=(guard "$task" "$real" "$key") + if [ -n "$allow_source" ]; then audit_args+=(--allow-source "$allow_source"); fi + FM_HOME="$FM_HOME" FM_DATA_OVERRIDE="$data_override" \ + "$SCRIPT_DIR/fm-lavish-audit.sh" "${audit_args[@]}" >/dev/null \ + || die "durable Lavish end guard refused: $key" +} + +find_active_row() { + local task=$1 real=$2 row ledger_text + ledger_text=$(ledger_rows "$task") || die "cannot read the Lavish ledger for $task" + row=$(printf '%s\n' "$ledger_text" | awk -F '\t' -v file="$real" '$1 == file { print; exit }') + [ -n "$row" ] || die "artifact is not an active recorded session for task $task: $real" + printf '%s\n' "$row" +} + +cmd_preflight_end() { + local task=${1-} artifact=${2-} real row key disposition source_id + [ "$#" -eq 2 ] || usage + validate_task_id "$task" + real=$(canonical_file "$artifact") + row=$(find_active_row "$task" "$real") + key=${row#*$'\t'}; key=${key%%$'\t'*} + disposition=${row##*$'\t'} + if [ "$disposition" = durable-review ]; then + source_id=$("$SCRIPT_DIR/fm-procevent-lavish.sh" source-id "$real") || exit 1 + guard_durable_end "$task" "$real" "$key" "$source_id" || exit 1 + fi +} + +cmd_end() { + local task=${1-} artifact=${2-} real row key disposition + [ "$#" -eq 2 ] || usage + validate_task_id "$task" + real=$(canonical_file "$artifact") + row=$(find_active_row "$task" "$real") + key=${row#*$'\t'}; key=${key%%$'\t'*} + disposition=${row##*$'\t'} + if [ "$disposition" = durable-review ]; then + guard_durable_end "$task" "$real" "$key" || exit 1 + fi + end_recorded_file "$task" "$real" "$key" +} + +cmd_end_with_source() { + [ "$#" -eq 3 ] || usage + local task=$1 artifact=$2 allow_source=$3 real row key disposition source_id + validate_task_id "$task" + [ -n "$allow_source" ] || die "a process-event source id is required" + real=$(canonical_file "$artifact") + row=$(find_active_row "$task" "$real") + key=${row#*$'\t'}; key=${key%%$'\t'*} + disposition=${row##*$'\t'} + source_id=$("$SCRIPT_DIR/fm-procevent-lavish.sh" source-id "$real") || exit 1 + [ "$source_id" = "$allow_source" ] || die "process-event source does not match the Lavish artifact" + if [ "$disposition" = durable-review ]; then + guard_durable_end "$task" "$real" "$key" "$allow_source" || exit 1 + fi + end_recorded_file "$task" "$real" "$key" +} + +cmd_end_ephemeral() { + local task=${1-} rows real key disposition + [ "$#" -eq 1 ] || usage + validate_task_id "$task" + while :; do + rows=$(ledger_rows "$task" ephemeral-worktree) || die "cannot read the Lavish ledger for $task" + [ -n "$rows" ] || break + while IFS=$'\t' read -r real key disposition; do + [ -n "$real" ] || continue + end_recorded_file "$task" "$real" "$key" || exit 1 + done < "$tmp" +const fs = require("node:fs"); +const rows = fs.readFileSync(process.env.LEDGER, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line)); +for (const row of rows) if (row.key === process.env.KEY && !row.ended_at) row.ended_at = new Date().toISOString(); +for (const row of rows) process.stdout.write(`${JSON.stringify(row)}\n`); +NODE + rc=$? + if [ "$rc" -ne 0 ] || ! chmod 0600 "$tmp" || ! mv -f "$tmp" "$ledger"; then + rm -f "$tmp" + fm_lock_release "$lock" + return 1 + fi + fm_lock_release "$lock" || return 1 +} + +cmd_finalize_key() { + local key=${1-} ledger task + [ "$#" -eq 1 ] || usage + [ -n "$key" ] && [[ "$key" != *$'\n'* ]] || die "Lavish key is invalid" + for ledger in "$STATE"/*.lavish-sessions; do + [ -e "$ledger" ] || [ -L "$ledger" ] || continue + ledger_is_safe "$ledger" || die "Lavish ledger is not a safe regular file: $ledger" + task=${ledger##*/}; task=${task%.lavish-sessions} + validate_task_id "$task" + finalize_key_in_ledger "$task" "$key" || die "cannot finalize Lavish ledger rows for $key" + done +} + +cmd_safe_park() { + local task=${1-} source=${2-} durable=${3-} source_real durable_real old_id new_id origin='' url + local home_real data_root task_root task_root_real durable_parent durable_parent_real durable_name existing_real + [ "$#" -eq 3 ] || usage + validate_task_id "$task" + source_real=$(canonical_file "$source") + [ -f "$source_real" ] && [ ! -L "$source_real" ] || die "source artifact is not a regular file: $source" + case "$durable" in + *"/../"*|*"/.."|*"/./"*|*"/." ) die "durable artifact path contains traversal components" ;; + esac + case "$durable" in "$FM_HOME/data/$task"/*) ;; *) die "durable artifact must be under $FM_HOME/data/$task" ;; esac + home_real=$(canonical_file "$FM_HOME") + data_root="$home_real/data" + if [ -e "$data_root" ] || [ -L "$data_root" ]; then + [ -d "$data_root" ] && [ ! -L "$data_root" ] || die "durable data directory is unsafe: $data_root" + else + mkdir -p "$data_root" || die "cannot create the durable data directory" + fi + task_root="$data_root/$task" + mkdir -p "$task_root" || die "cannot create the durable task directory" + [ -d "$task_root" ] && [ ! -L "$task_root" ] || die "durable task directory is unsafe: $task_root" + task_root_real=$(canonical_file "$task_root") + durable_parent=$(dirname "$durable") + durable_parent_real=$(canonical_existing_prefix "$durable_parent") \ + || die "cannot resolve the durable artifact directory: $durable_parent" + mkdir -p "$durable_parent" || die "cannot create the durable artifact directory" + durable_parent_real=$(canonical_file "$durable_parent") + case "$durable_parent_real" in + "$task_root_real"|"$task_root_real"/*) ;; + *) die "durable artifact resolves outside its task directory: $durable" ;; + esac + durable_name=$(basename "$durable") + case "$durable_name" in ''|.|..) die "durable artifact target is not a file path: $durable" ;; esac + durable_real="$durable_parent_real/$durable_name" + if [ -e "$durable" ] || [ -L "$durable" ]; then + [ -f "$durable" ] && [ ! -L "$durable" ] || die "durable artifact target is unsafe: $durable" + existing_real=$(canonical_file "$durable") + [ "$existing_real" = "$durable_real" ] || die "durable artifact resolves outside its task directory: $durable" + cmp -s "$source_real" "$durable_real" || die "durable artifact already exists with different contents: $durable" + else + cp -p "$source_real" "$durable_real" || die "cannot copy the artifact to durable storage" + fi + command -v lavish-axi >/dev/null 2>&1 || die "lavish-axi is not installed" + lavish_cli "$durable_real" >/dev/null || die "cannot serve the durable Lavish artifact" + cmd_register "$task" "$durable_real" durable-review >/dev/null || exit 1 + url=$(session_json_for_file "$durable_real" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).url||""))') + [ -n "$url" ] || die "durable Lavish session has no live URL" + curl -fsS --max-time 5 "$url" >/dev/null || die "durable Lavish URL is not live: $url" + old_id=$("$SCRIPT_DIR/fm-procevent-lavish.sh" source-id "$source_real") || exit 1 + new_id=$("$SCRIPT_DIR/fm-procevent-lavish.sh" source-id "$durable_real") || exit 1 + if [ -f "$STATE/procevent/$old_id.source" ]; then + "$SCRIPT_DIR/fm-procevent-lavish.sh" arm "$durable_real" --task-id "$task" >/dev/null || exit 1 + fi + if [ -f "$STATE/decision-bindings/$old_id.origin" ]; then + origin=$("$SCRIPT_DIR/fm-captain-hold.sh" binding "$old_id") || die "cannot read the old decision binding" + if [ "$origin" = '(any)' ]; then + "$SCRIPT_DIR/fm-captain-hold.sh" bind "$new_id" --any-origin >/dev/null || exit 1 + else + "$SCRIPT_DIR/fm-captain-hold.sh" bind "$new_id" "$origin" >/dev/null || exit 1 + fi + fi + if [ -f "$STATE/procevent/$old_id.source" ]; then + "$SCRIPT_DIR/fm-procevent-lavish.sh" retire "$source_real" >/dev/null || exit 1 + fi + if [ -n "$origin" ]; then + "$SCRIPT_DIR/fm-captain-hold.sh" unbind "$old_id" >/dev/null || exit 1 + fi + cmd_end "$task" "$source_real" >/dev/null || exit 1 + printf 'safe-parked: %s\nurl: %s\n' "$durable_real" "$url" +} + +case "${1:-}" in + register) shift; cmd_register "$@" ;; + register-auto) shift; cmd_register_auto "$@" ;; + safe-park) shift; cmd_safe_park "$@" ;; + preflight-end) shift; cmd_preflight_end "$@" ;; + end) shift; cmd_end "$@" ;; + end-with-source) shift; cmd_end_with_source "$@" ;; + end-ephemeral) shift; cmd_end_ephemeral "$@" ;; + poll-activity) shift; cmd_poll_activity "$@" ;; + finalize-key) shift; cmd_finalize_key "$@" ;; + remove-ledger) shift; cmd_remove_ledger "$@" ;; + -h|--help|help|'') usage ;; + *) usage ;; +esac diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index 07d6e80dbb9..feec319ff12 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -2,7 +2,7 @@ # Lavish adapter for the generic process-to-event runner. # # Usage: -# fm-procevent-lavish.sh arm +# fm-procevent-lavish.sh arm [--task-id ] # fm-procevent-lavish.sh classify # fm-procevent-lavish.sh terminal # fm-procevent-lavish.sh source-acknowledgements @@ -12,6 +12,7 @@ # fm-procevent-lavish.sh read # fm-procevent-lavish.sh source-id # fm-procevent-lavish.sh retire +# fm-procevent-lavish.sh retire-and-end # fm-procevent-lavish.sh poll # # classify Print the lifecycle state a handler should act on: feedback, ended, @@ -133,6 +134,8 @@ 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}" +LAVISH_STATE_FILE="${FM_LAVISH_STATE_FILE:-${LAVISH_AXI_STATE_DIR:-$HOME/.lavish-axi}/state.json}" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" @@ -140,10 +143,32 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" . "$SCRIPT_DIR/fm-wake-lib.sh" # shellcheck source=bin/fm-procevent-lib.sh . "$SCRIPT_DIR/fm-procevent-lib.sh" +# shellcheck source=bin/fm-lavish-lib.sh +. "$SCRIPT_DIR/fm-lavish-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } +LAVISH_STATE_DIR=$(fm_lavish_state_dir "$LAVISH_STATE_FILE") \ + || die "FM_LAVISH_STATE_FILE must be an absolute Lavish state.json path" usage() { sed -n '2,/^set -u$/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; exit 2; } +lavish_cli() { LAVISH_AXI_STATE_DIR="$LAVISH_STATE_DIR" command lavish-axi "$@"; } + +source_registration_matches() { + local source_file=$1 tmp status=1 + shift + [ -f "$source_file" ] && [ ! -L "$source_file" ] || return 1 + tmp=$(mktemp "${TMPDIR:-/tmp}/fm-lavish-source.XXXXXX") || return 2 + { + printf 'adapter=lavish\n' + printf 'argc=%s\n' "$#" + printf 'argv:\n' + printf '%s\n' "$@" + } > "$tmp" || { rm -f "$tmp"; return 2; } + if cmp -s "$tmp" "$source_file"; then status=0; fi + rm -f "$tmp" + return "$status" +} + # Canonical identity is physical, not the path string: Lavish itself keys a # session on the realpath of the artifact, so two names for one file are one # source and must never become two owners. @@ -162,20 +187,52 @@ cmd_source_id() { } cmd_arm() { - local artifact=${1-} id real + local artifact=${1-} id real task='' source_file source_published=0 + local -a poll_args [ -n "$artifact" ] || usage - [ "$#" -eq 1 ] || usage + if [ "$#" -eq 3 ] && [ "$2" = --task-id ]; then + task=$3 + elif [ "$#" -ne 1 ]; then + usage + fi command -v lavish-axi >/dev/null 2>&1 || die "lavish-axi is not installed" poll_retry_delay >/dev/null id=$(cmd_source_id "$artifact") || exit 1 real=$(perl -MCwd=realpath -e '$p = realpath($ARGV[0]); defined($p) or exit 1; print "$p\n"' "$artifact" 2>/dev/null) \ || die "cannot resolve the artifact path: $artifact" + poll_args=("$SCRIPT_DIR/fm-procevent-lavish.sh" poll "$real") + if [ -n "$task" ]; then poll_args+=(--task-id "$task"); fi + source_file="$STATE/procevent/$id.source" + if [ -e "$source_file" ] || [ -L "$source_file" ]; then + source_registration_matches "$source_file" "${poll_args[@]}" \ + || die "cannot arm over an existing process-event registration: $id" + else + if ! "$SCRIPT_DIR/fm-procevent.sh" register lavish "$id" -- "${poll_args[@]}"; then + if source_registration_matches "$source_file" "${poll_args[@]}" \ + && ! "$SCRIPT_DIR/fm-procevent.sh" retire "$id" --if-matches lavish -- "${poll_args[@]}" >/dev/null 2>&1; then + die "Lavish process-event registration failed and source rollback was refused: $id" + fi + die "cannot publish the Lavish process-event source: $id" + fi + source_published=1 + fi + if [ -n "$task" ]; then + if ! "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$real" "$task" >/dev/null; then + if [ "$source_published" -eq 1 ] && ! "$SCRIPT_DIR/fm-procevent.sh" retire "$id" --if-matches lavish -- "${poll_args[@]}" >/dev/null 2>&1; then + die "Lavish ownership registration failed and source rollback was refused: $id" + fi + die "cannot record Lavish ownership for $real" + fi + elif ! "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$real" >/dev/null; then + if [ "$source_published" -eq 1 ] && ! "$SCRIPT_DIR/fm-procevent.sh" retire "$id" --if-matches lavish -- "${poll_args[@]}" >/dev/null 2>&1; then + die "Lavish ownership registration failed and source rollback was refused: $id" + fi + die "cannot record Lavish ownership for $real" + fi # This adapter's own listener command, which runs the plain blocking form with # no --timeout-ms so completion is a server event, and absorbs only the exact # transient interruption. Registering raw poll output is what let that # interruption reach the runner as a captured result. - "$SCRIPT_DIR/fm-procevent.sh" register lavish "$id" \ - -- "$SCRIPT_DIR/fm-procevent-lavish.sh" poll "$real" || exit 1 printf 'armed: %s\n' "$id" printf 'artifact: %s\n' "$real" } @@ -187,6 +244,15 @@ cmd_retire() { "$SCRIPT_DIR/fm-procevent.sh" retire "$id" } +cmd_retire_and_end() { + local task=${1-} artifact=${2-} id + [ "$#" -eq 2 ] || usage + "$SCRIPT_DIR/fm-lavish-session.sh" preflight-end "$task" "$artifact" || exit 1 + id=$(cmd_source_id "$artifact") || exit 1 + "$SCRIPT_DIR/fm-lavish-session.sh" end-with-source "$task" "$artifact" "$id" || exit 1 + cmd_retire "$artifact" +} + # The bounded quiet retry described in the header. The bound is a constant # because it is a property of the transient response, not an operator choice; # only the delay takes an override, so a test can exercise the real bound @@ -264,10 +330,14 @@ poll_retry_delay() { } cmd_poll() { - local artifact=${1-} delay attempt=0 response cleanup_command rc filter_rc + local artifact=${1-} delay attempt=0 response cleanup_command rc filter_rc task= local pipeline_status [ -n "$artifact" ] || usage - [ "$#" -eq 1 ] || usage + if [ "$#" -eq 3 ] && [ "$2" = --task-id ]; then + task=$3 + elif [ "$#" -ne 1 ]; then + usage + fi command -v lavish-axi >/dev/null 2>&1 || die "lavish-axi is not installed" delay=$(poll_retry_delay) || exit 1 response=$(mktemp "${TMPDIR:-/tmp}/fm-lavish-poll.XXXXXX") || die "cannot stage the poll response" @@ -284,7 +354,12 @@ cmd_poll() { trap "$cleanup_command; trap - $signal; kill -$signal $$" "$signal" done while :; do - lavish-axi poll "$artifact" | poll_response_filter "$response" + if [ -n "$task" ]; then + "$SCRIPT_DIR/fm-lavish-session.sh" poll-activity "$artifact" "$task" || exit 1 + else + "$SCRIPT_DIR/fm-lavish-session.sh" poll-activity "$artifact" || exit 1 + fi + lavish_cli poll "$artifact" | poll_response_filter "$response" pipeline_status=("${PIPESTATUS[@]}") rc=${pipeline_status[0]} filter_rc=${pipeline_status[1]} @@ -334,7 +409,7 @@ cmd_acknowledge() { artifact=$(session_file "$file") [ -n "$artifact" ] || die "captured delivery has no session file" command -v lavish-axi >/dev/null 2>&1 || die "lavish-axi is not installed" - lavish-axi poll "$artifact" --ack "$delivery_id" --timeout-ms 1 >/dev/null + lavish_cli poll "$artifact" --ack "$delivery_id" --timeout-ms 1 >/dev/null } # Read one field of the response's leading `session:` block. Those fields are @@ -680,6 +755,7 @@ cmd_read() { case "${1-}" in arm) shift; cmd_arm "$@" ;; retire) shift; cmd_retire "$@" ;; + retire-and-end) shift; cmd_retire_and_end "$@" ;; poll) shift; cmd_poll "$@" ;; source-acknowledgements) [ "$#" -eq 1 ] || usage ;; acknowledge) shift; cmd_acknowledge "$@" ;; diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 8ba87418cef..4fb4adcadf9 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -7,13 +7,15 @@ # clone for PR-based ship tasks. # Removing state/.meta and landing the backlog transition are one step, not # two: bin/fm-backlog-transition-lib.sh owns that invariant, and both halves run -# under the task's own meta lock before this script reports success. Because the -# completion links (the PR, the report path, a local-main note) live only in the -# record being removed, the intended transition is recorded in -# state/.backlog-close first, so a process killed between the halves leaves -# the next session start enough to finish it; a landed transition removes that -# record. A transition that fails is fatal and loud, preserves its pending-close -# record, and is retried by the next session start. The transition is skipped on a +# under the task's own meta lock before this script reports success. Recorded +# ephemeral Lavish sessions are ended and verified before the intended transition +# is published, so a process killed between those stages leaves the task records +# intact and the next run can finish it; a landed transition removes that record. +# Because the completion links (the PR, the report path, a local-main note) live +# only in the record being removed, the intended transition is recorded in +# state/.backlog-close before that record is removed. A transition that fails +# is fatal and loud, preserves its pending-close record, and is retried by the +# next session start. The transition is skipped on a # config/backlog-backend=manual home and in a home that keeps no # data/backlog.md; those cases print the manual follow-up. An automatic-backend # home with a backlog but no compatible tasks-axi refuses before cleanup. @@ -2489,6 +2491,7 @@ cleanup_firstmate_home_children() { local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc child_busy_gen sub_state="$home/state" [ -d "$sub_state" ] || return 0 + preflight_firstmate_home_lavish_children "$home" || return 1 for child_meta in "$sub_state"/*.meta; do [ -e "$child_meta" ] || continue child_id=$(basename "$child_meta" .meta) @@ -2580,6 +2583,47 @@ cleanup_firstmate_home_children() { done } +preflight_firstmate_home_lavish_children() { + local home=$1 sub_state child_meta child_id child_kind child_wt child_home ledger ledger_id failures='' + sub_state="$home/state" + [ -d "$sub_state" ] || return 0 + for child_meta in "$sub_state"/*.meta; do + [ -e "$child_meta" ] || continue + child_id=$(basename "$child_meta" .meta) + child_kind=$(meta_value "$child_meta" kind) + [ -n "$child_kind" ] || child_kind=ship + if ! FM_HOME="$home" FM_STATE_OVERRIDE="$sub_state" \ + "$SCRIPT_DIR/fm-lavish-session.sh" end-ephemeral "$child_id" >/dev/null 2>&1; then + failures="$failures $child_id" + fi + if [ "$child_kind" = secondmate ]; then + child_wt=$(meta_value "$child_meta" worktree) + child_home=$(meta_value "$child_meta" home) + [ -n "$child_home" ] || child_home=$child_wt + if [ -n "$child_home" ] && ! preflight_firstmate_home_lavish_children "$child_home"; then + failures="$failures $child_id" + fi + fi + done + for ledger in "$sub_state"/*.lavish-sessions; do + [ -e "$ledger" ] || [ -L "$ledger" ] || continue + ledger_id=$(basename "$ledger" .lavish-sessions) + if [ -f "$sub_state/$ledger_id.meta" ] && [ ! -L "$sub_state/$ledger_id.meta" ]; then + continue + fi + { + if ! FM_HOME="$home" FM_STATE_OVERRIDE="$sub_state" \ + "$SCRIPT_DIR/fm-lavish-session.sh" end-ephemeral "$ledger_id" >/dev/null 2>&1; then + failures="$failures $ledger_id" + fi + } + done + if [ -n "$failures" ]; then + echo "REFUSED: forced secondmate cleanup could not end every ephemeral Lavish session for child tasks:$failures" >&2 + return 1 + fi +} + remove_secondmate_registry_entry() { local id=$1 tmp lock rc=0 acquired=0 [ -f "$SECONDMATE_REG" ] || return 0 @@ -2735,6 +2779,13 @@ BACKLOG_TRANSITION=$TEARDOWN_BACKLOG_TRANSITION BACKLOG_TRANSITION_FLAGS=() [ "$BACKLOG_TRANSITION" = close ] || BACKLOG_TRANSITION_FLAGS=(--retain) BACKLOG_SKIP_REASON= +if [ "$KIND" != secondmate ]; then + FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" \ + "$SCRIPT_DIR/fm-lavish-session.sh" end-ephemeral "$ID" || { + echo "error: could not end every recorded ephemeral Lavish session for $ID; preserving the worktree and task records" >&2 + exit 1 + } +fi if [ "$TEARDOWN_BACKLOG_APPLIES" = 1 ]; then backlog_done_args || { echo "error: the pending backlog $BACKLOG_TRANSITION for $ID is not replayable; refusing destructive teardown" >&2 @@ -2754,13 +2805,6 @@ else fi fi -# Every landed/discard-work refusal above has now passed (or --force skipped -# them). Fix 1 and Fix 2 (see script header) run here, unconditionally on -# --force, and before ANY destructive step below - a still-parked run or a -# leaked process can own live work in this exact worktree. Not for -# kind=secondmate: a secondmate home's own runtime lifecycle is owned by the -# dedicated process-event and firstmate-home removal machinery further below, -# not by task-worktree cleanup. if [ "$KIND" != secondmate ]; then conclude_task_no_mistakes_run "$WT" reap_task_worktree_processes worktree "$WT" "$TASK_TMP" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 23e6cd392ca..c1980785ae9 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -1364,6 +1364,12 @@ families_for_changed_path() { docs/fm-test-isolation-proof.json) printf '%s\n' pure-contract-unit ;; + data/fm-lavish-session-prune-f1/authorized-2026-09-08.json) + printf '%s\n' "__script__:fm-lavish-session.test.sh" + ;; + data/fm-lavish-session-prune-f1/report.md|\ + data/fm-lavish-session-prune-f1/upstream-issue-draft.md) + ;; .github/*|.tasks.toml|AGENTS.md|CLAUDE.md|CONTRIBUTING.md|\ docs/configuration.md|docs/supervision-protocols/*) printf '%s\n' pure-contract-unit diff --git a/docs/configuration.md b/docs/configuration.md index 08ddb298b15..fc929be6534 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -716,6 +716,13 @@ Never run the registered blocking source command directly in a conversational tu ## Process-to-event sources (state/procevent) +Lavish review sessions have a default 48-hour idle expiry in `bin/fm-lavish-audit.sh`. +The clock uses Lavish's `updated_at` for creation and re-serve activity plus Firstmate's ledgered arm time; an active poll, current task, retained hold, decision binding, feedback, or other live review owner still preserves the session. +Bootstrap reports how many open registry rows are past the expiry but never ends them. +Ending remains an explicit frozen-candidate `bin/fm-lavish-audit.sh apply` operation. +Plain apply accepts only eligible rows; `apply --authorized []` additionally accepts ambiguous existing-path rows explicitly listed in a frozen authority file carrying the 2026-09-08 ruling and three kept-board exclusions. +Unreadable or malformed primary or registered secondmate inventory refuses eligibility, and missing artifact paths remain unsupported by Lavish 0.1.63. + A long-polling external process is registered as a *source* through its adapter, whose header and `--help` own the commands and flags. `bin/fm-procevent.sh` owns the generic contract; built-in adapters retain their tracked `bin/fm-procevent-.sh` commands, while an explicitly bound external adapter routes through the trusted host contract above. `bin/fm-procevent-lavish.sh` is the first built-in adapter and wraps the published `lavish-axi poll` interface plus its optional delivery acknowledgement. diff --git a/docs/scripts.md b/docs/scripts.md index 6fe215ea957..4b0bbaebded 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -19,6 +19,8 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the bounded remote-ledger fleet snapshot to compact TOON; `--include-prs` adds live GitHub enrichment | | `fm-bearings-board.sh` | Build and arm the stable interactive `/bearings lavish` fleet board | +| `fm-lavish-audit.sh` | Audit Lavish registry ownership, freeze candidates, and apply bounded verified endings | +| `fm-lavish-session.sh` | Record Lavish ownership, end ephemeral sessions, and safe-park durable reviews | | `fm-secondmate-reconcile.sh` | Queue Bearings reconcile requests for later supervision delivery and ask each mismatched home through its durable inbox with a per-home cooldown | | `fm-update.sh` | Fast-forward-only self-update of firstmate and local or remote secondmate homes, with reload action classification | | `fm-secondmate-restart.sh` | Persist open conversational work, then restart eligible second mates or report the fallback outcome | @@ -77,6 +79,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-extension-launch-barrier.mjs` | Publish one exact static core-owned invocation group before package code runs | | `fm-extension.sh` | Expose extension binding commands through the tracked shell and remote-home command boundary | | `fm-procevent.sh` | Register, supervise, capture, classify, acknowledge, and safely retire built-in or explicitly bound process-event sources | +| `fm-procevent-lavish.sh` | Arm, poll, acknowledge, classify, and retire Lavish process-event sources | | `fm-procevent-remote-reply.sh` | Relay the remote-secondmate status stream through non-destructive process-event deltas | | `fm-procevent-quota.sh` | Wake Firstmate when tracked quota drops below a threshold, is exhausted, or cannot be polled | | `fm-procevent-when.sh` | Fire a trust-bound deterministic action at most once when its registered condition holds, then wake with the outcome | @@ -99,6 +102,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and secondmate syncs | | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | | `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation and config-reread delivery | +| `fm-lavish-lib.sh` | Shared Lavish state-path resolution and lifecycle helper primitives | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | | `fm-backlog-transition-lib.sh` | Pair task-record changes with their backlog transitions and replay interrupted closes | | `fm-quota-axi-lib.sh` | Shared `quota-axi` compatibility floor and quota snapshot schema validation | diff --git a/tests/fm-bearings-board-render.test.sh b/tests/fm-bearings-board-render.test.sh index afa6b9350cc..cf162057fd6 100755 --- a/tests/fm-bearings-board-render.test.sh +++ b/tests/fm-bearings-board-render.test.sh @@ -20,9 +20,36 @@ command -v node >/dev/null 2>&1 || { echo "skip: node not found"; exit 0; } make_home() { # local home="$TMP_ROOT/$1" fakebin - mkdir -p "$home/state" "$home/data" + mkdir -p "$home/state" "$home/data" "$home/lavish" fakebin=$(fm_fakebin "$home") - fm_fake_exit0 "$fakebin" lavish-axi + cat > "$fakebin/lavish-axi" <<'SH' +#!/usr/bin/env bash +set -u +if [ -f "$1" ]; then + ARTIFACT="$1" STATE_FILE="$LAVISH_AXI_STATE_DIR/state.json" node <<'NODE' +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const file = fs.realpathSync(process.env.ARTIFACT); +const state = fs.existsSync(process.env.STATE_FILE) + ? JSON.parse(fs.readFileSync(process.env.STATE_FILE, "utf8")) + : {sessions:{}}; +const key = crypto.createHash("sha256").update(file).digest("hex").slice(0, 16); +state.sessions[key] = { + key, + file, + url: "http://127.0.0.1:4387/session/" + key, + status: "open", + pending_prompts: 0, + prompts: [], + updated_at: new Date().toISOString(), +}; +fs.mkdirSync(path.dirname(process.env.STATE_FILE), {recursive:true}); +fs.writeFileSync(process.env.STATE_FILE, JSON.stringify(state, null, 2)); +NODE +fi +SH + chmod +x "$fakebin/lavish-axi" printf '%s\n' "$home" } @@ -35,6 +62,7 @@ render() { # [charted_more] [charted_warning_more] charted:$charted, charted_more:$more, charted_warning_more:$warning_more}' > "$data" PATH="$home/fakebin:$PATH" FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + LAVISH_AXI_STATE_DIR="$home/lavish" \ FM_PROCEVENT_CLAIM_ROOT="$home/procevent-claims" \ "$BOARD" build "$data" >/dev/null || fail "the board did not build" node "$HARNESS" "$home/.lavish/bearings-board.html" \ diff --git a/tests/fm-bearings-board.test.sh b/tests/fm-bearings-board.test.sh index d87acb652a2..cd602411dce 100644 --- a/tests/fm-bearings-board.test.sh +++ b/tests/fm-bearings-board.test.sh @@ -15,9 +15,26 @@ command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } make_home() { # local home="$TMP_ROOT/$1" fakebin - mkdir -p "$home/state" "$home/data" + mkdir -p "$home/state" "$home/data" "$home/lavish" fakebin=$(fm_fakebin "$home") - fm_fake_exit0 "$fakebin" lavish-axi + cat > "$fakebin/lavish-axi" <<'SH' +#!/usr/bin/env bash +set -u +if [ -f "${1:-}" ]; then + ARTIFACT="$1" STATE_FILE="${LAVISH_AXI_STATE_DIR}/state.json" node <<'NODE' +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const file = fs.realpathSync(process.env.ARTIFACT); +const state = fs.existsSync(process.env.STATE_FILE) ? JSON.parse(fs.readFileSync(process.env.STATE_FILE, 'utf8')) : {sessions:{}}; +const key = crypto.createHash('sha256').update(file).digest('hex').slice(0, 16); +state.sessions[key] = {key,file,url:`http://127.0.0.1:4387/session/${key}`,status:'open',pending_prompts:0,prompts:[],updated_at:new Date().toISOString()}; +fs.mkdirSync(require('node:path').dirname(process.env.STATE_FILE), {recursive:true}); +fs.writeFileSync(process.env.STATE_FILE, JSON.stringify(state, null, 2)); +NODE +fi +exit 0 +SH + chmod +x "$fakebin/lavish-axi" printf '%s\n' "$home" } @@ -26,6 +43,7 @@ run_board() { # shift PATH="$home/fakebin:$PATH" FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_LAVISH_STATE_FILE="$home/lavish/state.json" \ FM_PROCEVENT_CLAIM_ROOT="$home/procevent-claims" \ "$BOARD" "$@" } @@ -35,6 +53,7 @@ run_procevent() { # shift PATH="$home/fakebin:$PATH" FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_LAVISH_STATE_FILE="$home/lavish/state.json" \ FM_PROCEVENT_CLAIM_ROOT="$home/procevent-claims" \ "$ROOT/bin/fm-procevent.sh" "$@" } @@ -286,6 +305,17 @@ SH cat > "$home/fakebin/lavish-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" != poll ]; then + ARTIFACT="$1" STATE_FILE="${LAVISH_AXI_STATE_DIR}/state.json" node <<'NODE' +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const file = fs.realpathSync(process.env.ARTIFACT); +const state = {sessions:{}}; +const key = crypto.createHash('sha256').update(file).digest('hex').slice(0, 16); +state.sessions[key] = {key,file,url:`http://127.0.0.1:4387/session/${key}`,status:'open',pending_prompts:0,prompts:[],updated_at:new Date().toISOString()}; +fs.mkdirSync(path.dirname(process.env.STATE_FILE), {recursive:true}); +fs.writeFileSync(process.env.STATE_FILE, JSON.stringify(state, null, 2)); +NODE exit 0 fi cat < "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash @@ -403,6 +405,37 @@ ROWS pass "bootstrap enforces lavish-axi minimum version" } +test_lavish_registry_thresholds() { + local case_dir fakebin state out + case_dir="$TMP_ROOT/lavish-registry" + state="$case_dir/lavish/state.json" + mkdir -p "$case_dir/home/config" "$case_dir/home/state" "$case_dir/home/data" "$case_dir/lavish" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + fakebin=$(make_fake_toolchain "$case_dir") + STATE_FILE="$state" COUNT=19 node <<'NODE' +const fs=require("node:fs"); const sessions={}; +for(let i=0;i/dev/null 2>&1 || { echo "skip: tasks-axi not found"; exit make_home() { # local home="$TMP_ROOT/$1" fakebin - mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" + mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" "$home/lavish" cp "$ROOT/.tasks.toml" "$home/.tasks.toml" cat > "$home/data/backlog.md" <<'EOF' ## In flight @@ -29,7 +29,7 @@ make_home() { # ## Done EOF fakebin=$(fm_fakebin "$home") - fm_fake_exit0 "$fakebin" tmux treehouse no-mistakes gh gh-axi + fm_fake_exit0 "$fakebin" lavish-axi tmux treehouse no-mistakes gh gh-axi printf '%s\n' "$home" } @@ -41,10 +41,30 @@ run_lavish() { # shift PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + LAVISH_AXI_STATE_DIR="$home/lavish" \ FM_PROCEVENT_CLAIM_ROOT="$home/procevent-claims" \ "$ROOT/bin/fm-procevent-lavish.sh" "$@" } +seed_lavish_state() { # + local home=$1 artifact=$2 + ARTIFACT="$artifact" STATE_FILE="$home/lavish/state.json" node <<'NODE' +const fs = require("node:fs"); +const file = fs.realpathSync(process.env.ARTIFACT); +const session = { + key: "fixture", + file, + url: "http://127.0.0.1:4387/session/fixture", + status: "open", + pending_prompts: 0, + prompts: [], + chat: [], + updated_at: new Date().toISOString(), +}; +fs.writeFileSync(process.env.STATE_FILE, JSON.stringify({sessions:{fixture:session}}, null, 2)); +NODE +} + run_bearings() { # local home=$1 PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_BEARINGS_NOW=2026-07-14T12:00:00Z \ @@ -757,7 +777,7 @@ test_bound_channel_answers_close_at_answer_time() { artifact="$home/data/$id/review.html" printf '

Sample eval proposal

\n' > "$artifact" - fm_fake_exit0 "$home/fakebin" lavish-axi + seed_lavish_state "$home" "$artifact" sid=$(run_lavish "$home" source-id "$artifact") || fail "could not derive the review source id" run_captain "$home" bind "$sid" >/dev/null \ || fail "could not bind the review source to the keyed-answer intake" @@ -878,7 +898,7 @@ test_unbound_source_closes_no_hold() { artifact="$home/data/$id/review.html" printf '

Unbound

\n' > "$artifact" - fm_fake_exit0 "$home/fakebin" lavish-axi + seed_lavish_state "$home" "$artifact" sid=$(run_lavish "$home" source-id "$artifact") || fail "could not derive the unbound source id" run_lavish "$home" arm "$artifact" >/dev/null || fail "could not arm the unbound review" diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 43b15c6e818..81c2cdd1a01 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -112,6 +112,11 @@ fm_tasks_axi_backend_available() { return 1; } fm_tasks_axi_compatible() { return 1; } fm_backlog_backend_manual() { return 1; } SH + cat > "$fake/bin/fm-lavish-session.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fake/bin/fm-lavish-session.sh" ln -s "$ROOT/bin/fm-backlog-transition-lib.sh" "$fake/bin/fm-backlog-transition-lib.sh" # Meta with a nonexistent worktree so the dirty/treehouse blocks skip. cat > "$fake/state/$id.meta" < "$fake/bin/fm-lavish-session.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fake/bin/fm-lavish-session.sh" ln -s "$ROOT/bin/fm-backlog-transition-lib.sh" "$fake/bin/fm-backlog-transition-lib.sh" # No tasktmp= line at all. cat > "$fake/state/$id.meta" <board\n' > "$ARTIFACT" + +write_store() { + local status=${1:-open} + ARTIFACT="$ARTIFACT" STATUS="$status" node <<'NODE' > "$STATE_DIR/state.json" +const file = require("node:fs").realpathSync(process.env.ARTIFACT); +process.stdout.write(JSON.stringify({sessions:{abc123:{key:"abc123",file,url:"http://127.0.0.1:4387/session/abc123",status:process.env.STATUS,pending_prompts:0,prompts:[],chat:[],updated_at:"2026-09-08T00:00:00.000Z"}}}, null, 2)); +NODE +} + +cat > "$FAKE_BIN/lavish-axi" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" = end ]; then + ARTIFACT=$2 STATE_FILE="$LAVISH_AXI_STATE_DIR/state.json" node <<'NODE' +const fs = require("node:fs"); +const state = JSON.parse(fs.readFileSync(process.env.STATE_FILE, "utf8")); +const file = fs.realpathSync(process.env.ARTIFACT); +const session = Object.values(state.sessions).find(row => row.file === file); +if (!session) process.exit(2); +session.status = "ended"; +session.ended_by = "agent"; +fs.writeFileSync(process.env.STATE_FILE, JSON.stringify(state, null, 2)); +NODE + printf 'ended\n' + exit 0 +fi +if [ -f "${1:-}" ]; then + ARTIFACT=$1 STATE_FILE="$LAVISH_AXI_STATE_DIR/state.json" node <<'NODE' +const fs = require("node:fs"); +const state = JSON.parse(fs.readFileSync(process.env.STATE_FILE, "utf8")); +const file = fs.realpathSync(process.env.ARTIFACT); +if (!Object.values(state.sessions).some(row => row.file === file)) state.sessions.durable = {key:"durable",file,url:"http://127.0.0.1:4387/session/durable",status:"open",pending_prompts:0,prompts:[],chat:[],updated_at:"2026-09-08T00:01:00.000Z"}; +fs.writeFileSync(process.env.STATE_FILE, JSON.stringify(state, null, 2)); +NODE + exit 0 +fi +exit 0 +SH +chmod +x "$FAKE_BIN/lavish-axi" +fm_fake_exit0 "$FAKE_BIN" curl +fm_fake_exit0 "$FAKE_BIN" lsof + +if PATH="$FAKE_BIN:$PATH" FM_HOME="$HOME_DIR" FM_LAVISH_STATE_FILE="$TMP_ROOT/custom-lavish.json" \ + "$ROOT/bin/fm-lavish-session.sh" register task-one "$ARTIFACT" ephemeral-worktree >/dev/null 2>&1; then + fail "an unsupported custom Lavish state filename was accepted" +fi +pass "unsupported Lavish state filenames are rejected before lifecycle mutation" + +write_store open +PATH="$FAKE_BIN:$PATH" FM_HOME="$HOME_DIR" LAVISH_AXI_STATE_DIR="$STATE_DIR" \ + "$ROOT/bin/fm-lavish-session.sh" register task-one "$ARTIFACT" ephemeral-worktree >/dev/null +LEDGER="$HOME_DIR/state/task-one.lavish-sessions" +assert_present "$LEDGER" "register creates the private task ledger" +assert_grep '"task_id":"task-one"' "$LEDGER" "ledger binds the task" +assert_grep '"key":"abc123"' "$LEDGER" "ledger binds the Lavish key" +assert_grep '"disposition":"ephemeral-worktree"' "$LEDGER" "ledger records the disposition" + +PATH="$FAKE_BIN:$PATH" FM_HOME="$HOME_DIR" LAVISH_AXI_STATE_DIR="$STATE_DIR" \ + "$ROOT/bin/fm-lavish-session.sh" end-ephemeral task-one >/dev/null +[ "$(jq -r '.sessions.abc123.status' "$STATE_DIR/state.json")" = ended ] \ + || fail "end-ephemeral did not transition the isolated Lavish session" +assert_grep '"ended_at":' "$LEDGER" "verified end is recorded in the ledger" +pass "Lavish ledger registration and verified ephemeral end run through the executable" + +write_store open +PATH="$FAKE_BIN:$PATH" FM_HOME="$HOME_DIR" LAVISH_AXI_STATE_DIR="$STATE_DIR" \ + "$ROOT/bin/fm-lavish-session.sh" register task-one "$ARTIFACT" ephemeral-worktree >/dev/null +DURABLE="$HOME_DIR/data/task-one/board.html" +PATH="$FAKE_BIN:$PATH" FM_HOME="$HOME_DIR" LAVISH_AXI_STATE_DIR="$STATE_DIR" \ + "$ROOT/bin/fm-lavish-session.sh" safe-park task-one "$ARTIFACT" "$DURABLE" >/dev/null +assert_present "$DURABLE" "safe-park copies the artifact into task-owned durable data" +[ "$(jq -r '.sessions.abc123.status' "$STATE_DIR/state.json")" = ended ] \ + || fail "safe-park did not end the superseded worktree session" +[ "$(jq -r '.sessions.durable.status' "$STATE_DIR/state.json")" = open ] \ + || fail "safe-park did not leave the durable review live" +jq -s -e 'any(.[]; .key == "durable" and .disposition == "durable-review")' "$LEDGER" >/dev/null \ + || fail "safe-park did not record the durable ownership binding" +pass "safe-park verifies the durable replacement before ending the superseded session" + +DURABLE_SOURCE_ID=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$DURABLE") +mkdir -p "$HOME_DIR/state/decision-bindings" +printf 'schema=fm-decision-binding.v1\norigin=(any)\n' > "$HOME_DIR/state/decision-bindings/$DURABLE_SOURCE_ID.origin" +if PATH="$FAKE_BIN:$PATH" FM_HOME="$HOME_DIR" LAVISH_AXI_STATE_DIR="$STATE_DIR" \ + "$ROOT/bin/fm-lavish-session.sh" end task-one "$DURABLE" >/dev/null 2>&1; then + fail "durable end ignored an open decision binding" +fi +[ "$(jq -r '.sessions.durable.status' "$STATE_DIR/state.json")" = open ] \ + || fail "refused durable end still changed the session" +pass "durable end refuses while a captain review binding remains open" + +BEFORE_POLL=$(jq -s -r 'map(select(.key == "durable"))[0].last_polled_at' "$LEDGER") +sleep 1 +PATH="$FAKE_BIN:$PATH" FM_HOME="$HOME_DIR" LAVISH_AXI_STATE_DIR="$STATE_DIR" \ + "$ROOT/bin/fm-procevent-lavish.sh" poll "$DURABLE" --task-id task-one >/dev/null +AFTER_POLL=$(jq -s -r 'map(select(.key == "durable"))[0].last_polled_at' "$LEDGER") +BEFORE_POLL="$BEFORE_POLL" AFTER_POLL="$AFTER_POLL" node -e ' + if (!(Date.parse(process.env.AFTER_POLL) > Date.parse(process.env.BEFORE_POLL))) process.exit(1)' \ + || fail "a real poll iteration did not refresh the ledger activity clock" +pass "every Lavish poll iteration refreshes a portable ISO activity timestamp" + +OWNER_HOME="$TMP_ROOT/owner-home" +mkdir -p "$OWNER_HOME/state" +printf 'worktree=%s\nkind=ship\n' "$(dirname "$ARTIFACT")" > "$OWNER_HOME/state/owner-one.meta" +printf 'worktree=%s\nkind=ship\n' "$(dirname "$ARTIFACT")" > "$OWNER_HOME/state/owner-two.meta" +if PATH="$FAKE_BIN:$PATH" FM_HOME="$OWNER_HOME" LAVISH_AXI_STATE_DIR="$STATE_DIR" \ + "$ROOT/bin/fm-lavish-session.sh" register-auto "$ARTIFACT" >/dev/null 2>&1; then + fail "register-auto selected one of two matching task owners" +fi +assert_absent "$OWNER_HOME/state/owner-one.lavish-sessions" "ambiguous owner did not create the first ledger" +assert_absent "$OWNER_HOME/state/owner-two.lavish-sessions" "ambiguous owner did not create the second ledger" +pass "register-auto refuses multiple matching lifecycle owners" + +AUDIT_HOME="$TMP_ROOT/audit-home" +AUDIT_STATE="$TMP_ROOT/audit-lavish" +mkdir -p "$AUDIT_HOME/state/procevent" "$AUDIT_HOME/data/closed-task" "$AUDIT_STATE" "$TMP_ROOT/audit" +CURRENT="$TMP_ROOT/audit/current/board.html" +ELIGIBLE="$AUDIT_HOME/data/closed-task/board.html" +AMBIGUOUS="$TMP_ROOT/audit/unowned.html" +MISSING="$AUDIT_HOME/data/closed-task/missing.html" +FEEDBACK="$TMP_ROOT/audit/feedback.html" +HELD="$AUDIT_HOME/data/held-task/board.html" +EXPIRED="$TMP_ROOT/audit/expired.html" +EXPIRED_WORKTREE="$TMP_ROOT/.treehouse/example/expired.html" +mkdir -p "$(dirname "$CURRENT")" "$(dirname "$HELD")" +mkdir -p "$(dirname "$EXPIRED_WORKTREE")" +printf x > "$CURRENT"; printf x > "$ELIGIBLE"; printf x > "$AMBIGUOUS"; printf x > "$FEEDBACK"; printf x > "$HELD"; printf x > "$EXPIRED"; printf x > "$EXPIRED_WORKTREE" +cat > "$AUDIT_HOME/state/current-task.meta" < "$AUDIT_HOME/data/backlog.md" +CURRENT="$CURRENT" ELIGIBLE="$ELIGIBLE" AMBIGUOUS="$AMBIGUOUS" MISSING="$MISSING" FEEDBACK="$FEEDBACK" HELD="$HELD" EXPIRED="$EXPIRED" EXPIRED_WORKTREE="$EXPIRED_WORKTREE" node <<'NODE' > "$AUDIT_STATE/state.json" +const fs = require("node:fs"); +const rows = [ + ["current", process.env.CURRENT, "open", 0], + ["eligible", process.env.ELIGIBLE, "open", 0], + ["ambiguous", process.env.AMBIGUOUS, "open", 0], + ["missing", process.env.MISSING, "open", 0], + ["feedback", process.env.FEEDBACK, "feedback", 1], + ["held", process.env.HELD, "open", 0], + ["expired", process.env.EXPIRED, "open", 0], + ["expired-worktree", process.env.EXPIRED_WORKTREE, "open", 0], +]; +const sessions = {}; +for (const [key,file,status,pending_prompts] of rows) sessions[key] = {key,file:fs.existsSync(file)?fs.realpathSync(file):file,url:`http://127.0.0.1:4387/session/${key}`,status,pending_prompts,prompts:pending_prompts?[{tag:"message"}]:[],chat:[],updated_at:key.startsWith("expired")?"2020-01-01T00:00:00.000Z":new Date().toISOString()}; +process.stdout.write(JSON.stringify({sessions}, null, 2)); +NODE + +FREEZE="$TMP_ROOT/candidates.jsonl" +OUT=$(PATH="$FAKE_BIN:$PATH" FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" \ + "$ROOT/bin/fm-lavish-audit.sh" audit --freeze "$FREEZE") +assert_contains "$OUT" $'preserve\tcurrent\t' "current task ownership is preserved" +assert_contains "$OUT" $'eligible\teligible\t' "positively closed task is eligible" +assert_contains "$OUT" $'ambiguous\tambiguous\t' "unowned session remains ambiguous" +assert_contains "$OUT" $'ambiguous\tmissing\tunsupported-by-current-Lavish' "missing artifact is unsupported" +assert_contains "$OUT" $'preserve\tfeedback\t' "pending feedback is preserved" +assert_contains "$OUT" $'preserve\theld\tretained-backlog-hold:parked' "retained backlog hold is preserved" +assert_contains "$OUT" $'eligible\texpired\tidle-expired:48h' "48-hour idle session is eligible" +assert_contains "$OUT" $'preserve\texpired-worktree\tretained-worktree-file' "retained worktree wins over idle expiry" +[ "$(wc -l < "$FREEZE" | tr -d ' ')" = 2 ] || fail "freeze did not contain exactly the eligible sessions" +assert_grep '"key":"eligible"' "$FREEZE" "freeze contains the eligible key" +pass "audit classifies every isolated registry row conservatively and freezes only eligible rows" + +PATH="$FAKE_BIN:$PATH" FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" \ + "$ROOT/bin/fm-lavish-audit.sh" apply "$FREEZE" --batch-size 1 >/dev/null +[ "$(jq -r '.sessions.eligible.status' "$AUDIT_STATE/state.json")" = ended ] \ + || fail "apply did not end its frozen eligible session" +[ "$(jq -r '.sessions.ambiguous.status' "$AUDIT_STATE/state.json")" = open ] \ + || fail "apply changed an ambiguous session" +pass "apply ends only frozen eligible sessions and verifies the transition" + +EMPTY_CANDIDATE="$TMP_ROOT/empty-candidates.jsonl" +: > "$EMPTY_CANDIDATE" +AUTHORITY="$TMP_ROOT/authority.json" +AMBIGUOUS="$AMBIGUOUS" AUDIT_STATE="$AUDIT_STATE" node <<'NODE' > "$AUTHORITY" +const fs = require("node:fs"); +const row = JSON.parse(fs.readFileSync(`${process.env.AUDIT_STATE}/state.json`, "utf8")).sessions.ambiguous; +process.stdout.write(JSON.stringify({ + schema:"fm-lavish-session-authority.v1", + ruling_date:"2026-09-08", + frozen_at:"2026-09-08", + ruling:"Apply only captain-authorized ambiguous existing-path sessions, except the three links mentioned on 2026-09-08.", + authorized:[{...row,classification:"ambiguous"}], + excluded:[ + {key:"7f59a8c16dff9f19",url:"http://127.0.0.1:4387/session/7f59a8c16dff9f19",file:"/Users/ivan/Projects/firstmate/data/nancy-tennis-directions-board-b2/board/index.html",reason:"kept board named in the 2026-09-08 ruling"}, + {key:"4ae99e8ad06d4a8c",url:"http://127.0.0.1:4387/session/4ae99e8ad06d4a8c",file:"/Users/ivan/.treehouse/firstmate-bd0d1d/8/firstmate/data/ally-screener-paid-media/board/index.html",reason:"kept board named in the 2026-09-08 ruling"}, + {key:"cc73671c247bff78",url:"http://127.0.0.1:4387/session/cc73671c247bff78",file:"/Users/ivan/Projects/firstmate/data/syd-board-b1/board/index.html",reason:"kept board named in the 2026-09-08 ruling"}, + ], +}, null, 2)); +NODE +PATH="$FAKE_BIN:$PATH" FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" \ + "$ROOT/bin/fm-lavish-audit.sh" apply "$EMPTY_CANDIDATE" --authorized "$AUTHORITY" --batch-size 1 >/dev/null +[ "$(jq -r '.sessions.ambiguous.status' "$AUDIT_STATE/state.json")" = ended ] \ + || fail "authorized apply did not end the frozen ambiguous session" +pass "authorized apply requires the exact ruling and three protected board exclusions" + +SUMMARY=$(PATH="$FAKE_BIN:$PATH" FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" "$ROOT/bin/fm-lavish-audit.sh" summary) +assert_contains "$SUMMARY" 'total=8' "summary counts total registry rows" +assert_contains "$SUMMARY" 'open=4' "summary counts open registry rows after apply" +assert_contains "$SUMMARY" 'feedback=1' "summary counts feedback rows" +assert_contains "$SUMMARY" 'ended=3' "summary counts ended rows" +assert_contains "$SUMMARY" 'missing_file=1' "summary counts open missing-file rows" +assert_contains "$SUMMARY" 'past_expiry=1' "summary counts expired preserved rows after apply" +pass "summary distinguishes registry counts from live connections" + +printf '{not-json}\n' > "$AUDIT_HOME/state/bad-owner.lavish-sessions" +if PATH="$FAKE_BIN:$PATH" FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" \ + "$ROOT/bin/fm-lavish-audit.sh" audit >/dev/null 2>&1; then + fail "audit converted malformed ownership inventory into an empty eligible inventory" +fi +pass "malformed ownership inventory refuses the whole audit" + +fm_test_cleanup +printf 'all fm-lavish-session tests passed\n' diff --git a/tests/fm-procevent-lavish-ack.test.sh b/tests/fm-procevent-lavish-ack.test.sh index fec8c065791..54d2c5b121e 100755 --- a/tests/fm-procevent-lavish-ack.test.sh +++ b/tests/fm-procevent-lavish-ack.test.sh @@ -83,18 +83,25 @@ chmod +x "$FAKE_BIN/lavish-axi" run_scenario() { # local scenario=$1 home="$TMP_ROOT/$1-home" artifact="$TMP_ROOT/$1.html" id out - mkdir -p "$home/state" + artifact="$home/data/$scenario/review.html" + mkdir -p "$home/state" "$home/data/$scenario" "$home/lavish" printf '

%s

\n' "$scenario" > "$artifact" + ARTIFACT="$artifact" node <<'NODE' > "$home/lavish/state.json" +const fs = require("node:fs"); +const file = fs.realpathSync(process.env.ARTIFACT); +const session = {key:"session",file,url:"http://127.0.0.1:4387/session/session",status:"open",pending_prompts:0,prompts:[],chat:[],updated_at:"2026-09-08T00:00:00.000Z"}; +process.stdout.write(JSON.stringify({sessions:{session}}, null, 2)); +NODE id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$artifact") LAVISH_SOURCE_ID=$id LAVISH_SCENARIO=$scenario LAVISH_LOG="$TMP_ROOT/$scenario.log" \ - PATH="$FAKE_BIN:$PATH" FM_HOME="$home" \ + PATH="$FAKE_BIN:$PATH" FM_HOME="$home" LAVISH_AXI_STATE_DIR="$home/lavish" \ "$ROOT/bin/fm-procevent-lavish.sh" arm "$artifact" >/dev/null if [ "$scenario" = bound-feed-failure ]; then FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ "$ROOT/bin/fm-captain-hold.sh" bind "$id" >/dev/null fi out=$(LAVISH_SOURCE_ID=$id LAVISH_SCENARIO=$scenario LAVISH_LOG="$TMP_ROOT/$scenario.log" \ - PATH="$FAKE_BIN:$PATH" FM_HOME="$home" \ + PATH="$FAKE_BIN:$PATH" FM_HOME="$home" LAVISH_AXI_STATE_DIR="$home/lavish" \ FM_PROCEVENT_MAX_OUTPUT_BYTES=$([ "$scenario" = truncated ] || [ "$scenario" = partial-truncated ] && printf 256 || printf 1048576) \ "$ROOT/bin/fm-procevent.sh" start "$id" 2>&1) printf '%s\n%s\n%s\n' "$home" "$id" "$out" diff --git a/tests/fm-procevent-lavish-live-e2e.test.sh b/tests/fm-procevent-lavish-live-e2e.test.sh index bfe161f9028..49ad30b1918 100755 --- a/tests/fm-procevent-lavish-live-e2e.test.sh +++ b/tests/fm-procevent-lavish-live-e2e.test.sh @@ -21,7 +21,7 @@ TMP_ROOT=$(fm_test_tmproot fm-procevent-lavish-live) BUILD="$TMP_ROOT/lavish-build" HOME_DIR="$TMP_ROOT/home" STATE_DIR="$TMP_ROOT/lavish-state" -ARTIFACT="$TMP_ROOT/review.html" +ARTIFACT="$HOME_DIR/data/live-review/review.html" SERVER_STARTED=0 SOURCE_ID= @@ -48,7 +48,7 @@ CHECKED_OUT_COMMIT=$(git -C "$LAVISH_SOURCE" rev-parse --verify 'HEAD^{commit}' [ -d "$LAVISH_SOURCE/node_modules" ] \ || fail "patched Lavish dependencies are absent; this guard never installs them" -mkdir -p "$BUILD" "$HOME_DIR/state" "$STATE_DIR" +mkdir -p "$BUILD" "$HOME_DIR/state" "$HOME_DIR/data/live-review" "$STATE_DIR" git -C "$LAVISH_SOURCE" archive "$EXPECTED_COMMIT_FULL" | tar -x -C "$BUILD" \ || fail "could not archive patched Lavish into the scratch build" ln -s "$LAVISH_SOURCE/node_modules" "$BUILD/node_modules" diff --git a/tests/fm-procevent.test.sh b/tests/fm-procevent.test.sh index c28262ddf74..08eea31549f 100755 --- a/tests/fm-procevent.test.sh +++ b/tests/fm-procevent.test.sh @@ -60,6 +60,18 @@ procevent_teardown() { } trap procevent_teardown EXIT new_home() { mkdir -p "$1/state"; } +lavish_arm_fixture() { # + local fake_bin=$1 home=$2 artifact=$3 + mkdir -p "$home/lavish" + ARTIFACT="$artifact" node <<'NODE' > "$home/lavish/state.json" +const fs = require("node:fs"); +const file = fs.realpathSync(process.env.ARTIFACT); +const session = {key:"fixture",file,url:"http://127.0.0.1:4387/session/fixture",status:"open",pending_prompts:0,prompts:[],chat:[],updated_at:"2026-09-08T00:00:00.000Z"}; +process.stdout.write(JSON.stringify({sessions:{fixture:session}}, null, 2)); +NODE + PATH="$fake_bin:$PATH" FM_HOME="$home" LAVISH_AXI_STATE_DIR="$home/lavish" \ + "$ROOT/bin/fm-procevent-lavish.sh" arm "$artifact" >/dev/null +} wake_payloads() { awk -F '\t' '{print $5}' "$1/state/.wake-queue" 2>/dev/null; } first_result() { # : print the first captured result, if any @@ -594,11 +606,12 @@ else fi SH chmod +x "$LAVISH_BIN/lavish-axi" -REVIEW_ART="$TMP_ROOT/review.html" +REVIEW_ART="$HLT/data/hlt-review/review.html" +mkdir -p "$(dirname "$REVIEW_ART")" printf '

review

\n' > "$REVIEW_ART" lavish_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$REVIEW_ART") PE_TRACKED+=("$HLT|$lavish_id") -PATH="$LAVISH_BIN:$PATH" FM_HOME="$HLT" "$ROOT/bin/fm-procevent-lavish.sh" arm "$REVIEW_ART" >/dev/null +lavish_arm_fixture "$LAVISH_BIN" "$HLT" "$REVIEW_ART" for _ in $(seq 1 6); do PATH="$LAVISH_BIN:$PATH" pe "$HLT" reconcile >/dev/null sleep 0.3 @@ -634,12 +647,12 @@ cat > "$EMPTY_BIN/lavish-axi" <<'SH' printf 'session:\n file: /quiet.html\n status: ended\n ended_by: user\n' SH chmod +x "$EMPTY_BIN/lavish-axi" -QUIET_ART="$TMP_ROOT/quiet-board.html" +QUIET_ART="$HEMPTY/data/quiet-review/quiet-board.html" +mkdir -p "$(dirname "$QUIET_ART")" printf '

quiet

\n' > "$QUIET_ART" quiet_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$QUIET_ART") PE_TRACKED+=("$HEMPTY|$quiet_id") -PATH="$EMPTY_BIN:$PATH" FM_HOME="$HEMPTY" \ - "$ROOT/bin/fm-procevent-lavish.sh" arm "$QUIET_ART" >/dev/null +lavish_arm_fixture "$EMPTY_BIN" "$HEMPTY" "$QUIET_ART" quiet_out=$(PATH="$EMPTY_BIN:$PATH" pe "$HEMPTY" start "$quiet_id" 2>&1) assert_not_contains "$quiet_out" "not-autohandled" \ "a durably silenced result was reported as still unacknowledged" @@ -680,12 +693,12 @@ cat > "$ANSWER_BIN/lavish-axi" <<'SH' printf 'session:\n file: /answered.html\n status: feedback\n session_ended: true\n ended_by: user\nprompts[1]{tag,text,prompt}:\n "choice","Option B","Context data: {\\"question\\":\\"noop-check-routing\\",\\"answer\\":\\"b\\"}"\n' SH chmod +x "$ANSWER_BIN/lavish-axi" -ANSWER_ART="$TMP_ROOT/answered-board.html" +ANSWER_ART="$HANSWER/data/answered-review/answered-board.html" +mkdir -p "$(dirname "$ANSWER_ART")" printf '

answered

\n' > "$ANSWER_ART" answer_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$ANSWER_ART") PE_TRACKED+=("$HANSWER|$answer_id") -PATH="$ANSWER_BIN:$PATH" FM_HOME="$HANSWER" \ - "$ROOT/bin/fm-procevent-lavish.sh" arm "$ANSWER_ART" >/dev/null +lavish_arm_fixture "$ANSWER_BIN" "$HANSWER" "$ANSWER_ART" PATH="$ANSWER_BIN:$PATH" pe "$HANSWER" reconcile >/dev/null wait_for "$HANSWER/state/.wake-queue" \ || fail "a board close carrying the captain's real answer produced no wake" @@ -742,13 +755,13 @@ export FM_LAVISH_POLL_RETRY_DELAY=0 # Two interruptions, then the captain's real feedback: the retries are silent and # only the feedback becomes a captured result and a check wake. HRETRY="$TMP_ROOT/hretry"; new_home "$HRETRY" -RETRY_ART="$TMP_ROOT/retry-board.html" +RETRY_ART="$HRETRY/data/retry-review/retry-board.html" +mkdir -p "$(dirname "$RETRY_ART")" printf '

retry

\n' > "$RETRY_ART" retry_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$RETRY_ART") PE_TRACKED+=("$HRETRY|$retry_id") LAVISH_COUNT="$TMP_ROOT/retry-count"; LAVISH_SCRIPT="interrupt interrupt feedback" -PATH="$LAVISH_SCRIPTED_BIN:$PATH" FM_HOME="$HRETRY" \ - "$ROOT/bin/fm-procevent-lavish.sh" arm "$RETRY_ART" >/dev/null +lavish_arm_fixture "$LAVISH_SCRIPTED_BIN" "$HRETRY" "$RETRY_ART" PATH="$LAVISH_SCRIPTED_BIN:$PATH" pe "$HRETRY" reconcile >/dev/null wait_for "$HRETRY/state/.wake-queue" || fail "feedback after interrupted polls produced no wake" [ "$(cat "$LAVISH_COUNT")" = 3 ] \ @@ -766,13 +779,13 @@ pass "a transient Lavish poll interruption is retried quietly and never announce # Exhaustion is news: after the bounded retries the same exact response is # captured and announced normally rather than being swallowed forever. HEXH="$TMP_ROOT/hexh"; new_home "$HEXH" -EXH_ART="$TMP_ROOT/exhaust-board.html" +EXH_ART="$HEXH/data/exhaust-review/exhaust-board.html" +mkdir -p "$(dirname "$EXH_ART")" printf '

exhaust

\n' > "$EXH_ART" exh_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$EXH_ART") PE_TRACKED+=("$HEXH|$exh_id") LAVISH_COUNT="$TMP_ROOT/exhaust-count"; LAVISH_SCRIPT="interrupt" -PATH="$LAVISH_SCRIPTED_BIN:$PATH" FM_HOME="$HEXH" \ - "$ROOT/bin/fm-procevent-lavish.sh" arm "$EXH_ART" >/dev/null +lavish_arm_fixture "$LAVISH_SCRIPTED_BIN" "$HEXH" "$EXH_ART" PATH="$LAVISH_SCRIPTED_BIN:$PATH" pe "$HEXH" start "$exh_id" >/dev/null [ "$(cat "$LAVISH_COUNT")" = 13 ] \ || fail "the retry bound polled $(cat "$LAVISH_COUNT") times, not the first poll plus 12 bounded retries" @@ -789,13 +802,13 @@ pass "an interruption that outlives the bounded retries is captured and announce # A different SERVER_ERROR is a genuine error, never a retry: no fail-open drift # from the one exact transient response this adapter owns. HOTHER="$TMP_ROOT/hother"; new_home "$HOTHER" -OTHER_ART="$TMP_ROOT/other-board.html" +OTHER_ART="$HOTHER/data/other-review/other-board.html" +mkdir -p "$(dirname "$OTHER_ART")" printf '

other

\n' > "$OTHER_ART" other_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$OTHER_ART") PE_TRACKED+=("$HOTHER|$other_id") LAVISH_COUNT="$TMP_ROOT/other-count"; LAVISH_SCRIPT="other-server-error" -PATH="$LAVISH_SCRIPTED_BIN:$PATH" FM_HOME="$HOTHER" \ - "$ROOT/bin/fm-procevent-lavish.sh" arm "$OTHER_ART" >/dev/null +lavish_arm_fixture "$LAVISH_SCRIPTED_BIN" "$HOTHER" "$OTHER_ART" PATH="$LAVISH_SCRIPTED_BIN:$PATH" pe "$HOTHER" start "$other_id" >/dev/null [ "$(cat "$LAVISH_COUNT")" = 1 ] \ || fail "an unrelated SERVER_ERROR was retried $(cat "$LAVISH_COUNT") times instead of surfacing at once" @@ -809,13 +822,13 @@ unset FM_LAVISH_POLL_RETRY_DELAY # A whitespace variant is not the exact transient response and must surface on # the first poll instead of drifting into the quiet retry policy. HNEAR="$TMP_ROOT/hnear"; new_home "$HNEAR" -NEAR_ART="$TMP_ROOT/near-board.html" +NEAR_ART="$HNEAR/data/near-review/near-board.html" +mkdir -p "$(dirname "$NEAR_ART")" printf '

near

\n' > "$NEAR_ART" near_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$NEAR_ART") PE_TRACKED+=("$HNEAR|$near_id") LAVISH_COUNT="$TMP_ROOT/near-count"; LAVISH_SCRIPT="near-interrupt feedback" -PATH="$LAVISH_SCRIPTED_BIN:$PATH" FM_HOME="$HNEAR" FM_LAVISH_POLL_RETRY_DELAY=0 \ - "$ROOT/bin/fm-procevent-lavish.sh" arm "$NEAR_ART" >/dev/null +FM_LAVISH_POLL_RETRY_DELAY=0 lavish_arm_fixture "$LAVISH_SCRIPTED_BIN" "$HNEAR" "$NEAR_ART" PATH="$LAVISH_SCRIPTED_BIN:$PATH" FM_HOME="$HNEAR" pe "$HNEAR" start "$near_id" >/dev/null [ "$(cat "$LAVISH_COUNT")" = 1 ] \ || fail "a near-match interruption was retried instead of surfacing on its first poll" @@ -828,7 +841,8 @@ pass "only the literal two-line interruption enters the quiet retry policy" # The public arm boundary refuses invalid retry intervals before it publishes a # source registration, rather than arming a listener that can only fail later. HINVALID="$TMP_ROOT/hinvalid"; new_home "$HINVALID" -INVALID_ART="$TMP_ROOT/invalid-delay-board.html" +INVALID_ART="$HINVALID/data/invalid-review/invalid-delay-board.html" +mkdir -p "$(dirname "$INVALID_ART")" printf '

invalid delay

\n' > "$INVALID_ART" invalid_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$INVALID_ART") for invalid_delay in 61 invalid; do @@ -857,7 +871,8 @@ quoted_staged=("$QUOTED_TMPDIR"/fm-lavish-poll.*) pass "poll cleanup safely handles an apostrophe-containing TMPDIR" HSTREAM="$TMP_ROOT/hstream"; new_home "$HSTREAM" -STREAM_ART="$TMP_ROOT/stream-board.html" +STREAM_ART="$HSTREAM/data/stream-review/stream-board.html" +mkdir -p "$(dirname "$STREAM_ART")" STREAM_TMPDIR="$TMP_ROOT/stream-stage" LAVISH_STREAM_READY="$TMP_ROOT/stream-ready" LAVISH_STREAM_RELEASE="$TMP_ROOT/stream-release" @@ -866,8 +881,7 @@ printf '

stream

\n' > "$STREAM_ART" stream_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$STREAM_ART") PE_TRACKED+=("$HSTREAM|$stream_id") LAVISH_COUNT="$TMP_ROOT/stream-count"; LAVISH_SCRIPT="stream" -PATH="$LAVISH_SCRIPTED_BIN:$PATH" FM_HOME="$HSTREAM" \ - "$ROOT/bin/fm-procevent-lavish.sh" arm "$STREAM_ART" >/dev/null +lavish_arm_fixture "$LAVISH_SCRIPTED_BIN" "$HSTREAM" "$STREAM_ART" PATH="$LAVISH_SCRIPTED_BIN:$PATH" TMPDIR="$STREAM_TMPDIR" \ LAVISH_STREAM_READY="$LAVISH_STREAM_READY" LAVISH_STREAM_RELEASE="$LAVISH_STREAM_RELEASE" \ FM_PROCEVENT_MAX_OUTPUT_BYTES=100 pe "$HSTREAM" reconcile >/dev/null diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh index beda4d1bc86..7d3168aed15 100755 --- a/tests/fm-test-run.test.sh +++ b/tests/fm-test-run.test.sh @@ -308,6 +308,27 @@ test_changed_dependency_selection_and_unmapped_failure() { pass "changed selection covers dependents and fails closed for unmapped source" } +test_changed_lavish_authority_selects_lavish_test() { + local tmp repo listed + tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-lavish-authority.XXXXXX") + repo="$tmp/repo" + mkdir -p "$repo/bin" "$repo/tests" "$repo/data/fm-lavish-session-prune-f1" + cp "$RUNNER" "$repo/bin/fm-test-run.sh" + printf '#!/usr/bin/env bash\nexit 0\n' >"$repo/tests/fm-lavish-session.test.sh" + printf '{}\n' >"$repo/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json" + chmod +x "$repo/bin/fm-test-run.sh" "$repo/tests/fm-lavish-session.test.sh" + git -C "$repo" init -q + git -C "$repo" add . + git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm baseline + + printf '{"changed":true}\n' >"$repo/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json" + listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) + assert_contains "$listed" "tests/fm-lavish-session.test.sh" \ + "Lavish authority changes must select their behavior test" + rm -rf "$tmp" + pass "changed Lavish authority selects its behavior test" +} + # A direct test reference is per-script evidence. Widening it to the referencing # test's whole family is what turned a one-line change to a shared helper into # every real-Herdr E2E, including scripts with no dependency on it at all. @@ -1453,6 +1474,7 @@ test_single_script_selection test_changed_file_selection_is_conservative test_changed_runner_surfaces_select_their_family test_changed_dependency_selection_and_unmapped_failure +test_changed_lavish_authority_selects_lavish_test test_changed_bin_reference_selects_per_script_not_per_family test_changed_uses_bounded_automatic_concurrency test_script_list_uses_bounded_automatic_concurrency