From 7e3fc3a1eb4eb3d0a95a8d1eb5080e2d984b9ec7 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 8 Sep 2026 08:04:05 +0800 Subject: [PATCH 01/12] fix: own lavish session lifecycle --- .agents/skills/bootstrap-diagnostics/SKILL.md | 3 + .agents/skills/process-event-sources/SKILL.md | 7 +- bin/fm-bootstrap.sh | 23 ++ bin/fm-lavish-audit.sh | 279 +++++++++++++++++ bin/fm-lavish-session.sh | 284 ++++++++++++++++++ bin/fm-procevent-lavish.sh | 29 +- bin/fm-teardown.sh | 8 + tests/fm-bootstrap.test.sh | 36 ++- tests/fm-lavish-session.test.sh | 145 +++++++++ tests/fm-procevent-lavish-ack.test.sh | 1 + tests/fm-procevent-lavish-live-e2e.test.sh | 1 + tests/fm-procevent.test.sh | 1 + 12 files changed, 812 insertions(+), 5 deletions(-) create mode 100755 bin/fm-lavish-audit.sh create mode 100755 bin/fm-lavish-session.sh create mode 100755 tests/fm-lavish-session.test.sh 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..5c516d2b3bb 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -27,9 +27,14 @@ 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. +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. +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/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..266b025648e --- /dev/null +++ b/bin/fm-lavish-audit.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +# Audit the Lavish registry against Firstmate lifecycle ownership without mutation. +# +# Usage: +# fm-lavish-audit.sh [audit] [--freeze ] +# fm-lavish-audit.sh summary +# fm-lavish-audit.sh apply [--batch-size <1..50>] +# +# 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. +# It never deletes Lavish records, Firstmate state, chat, attachments, or files, +# and it never edits Lavish state.json. +# +# Ownership is read across this FM_HOME and every local 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. +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}" + +die() { printf 'error: %s\n' "$1" >&2; exit 1; } +usage() { sed -n '2,/^set -u$/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; exit 2; } + +make_homes_file() { + local out=$1 registry="$FM_HOME/data/secondmates.md" home + : > "$out" + printf '%s\n' "$FM_HOME" >> "$out" + if [ -f "$registry" ] && [ ! -L "$registry" ]; then + sed -n 's/.*(home: \([^;)]*\).*/\1/p' "$registry" | while IFS= read -r home; do + [ -n "$home" ] && printf '%s\n' "$home" + done >> "$out" + fi + awk '!seen[$0]++' "$out" > "$out.unique" && mv "$out.unique" "$out" +} + +run_audit_node() { + local mode=$1 homes_file=$2 freeze=${3-} + AUDIT_MODE="$mode" HOMES_FILE="$homes_file" FREEZE_FILE="$freeze" \ + LAVISH_STATE_FILE="$LAVISH_STATE_FILE" ATTACHED_FILE="${FM_LAVISH_ATTACHED_KEYS_FILE:-}" 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); }; +let state; +try { state = JSON.parse(fs.readFileSync(process.env.LAVISH_STATE_FILE, "utf8")); } +catch (error) { fail(`cannot read Lavish state: ${error.message}`); } +if (!state.sessions || typeof state.sessions !== "object" || Array.isArray(state.sessions)) fail("Lavish state has no session registry object"); +const homes = fs.readFileSync(process.env.HOMES_FILE, "utf8").split("\n").filter(Boolean); +const meta = []; +const closed = new Set(); +const ledgers = new Map(); +const sources = new Set(); +const decisions = new Set(); +const unacked = new Set(); +let unreadableHome = false; +let activePollRegistrations = 0; +const sourceId = file => `lavish-${crypto.createHash("sha256").update(file).digest("hex").slice(0,16)}`; +const readLines = file => fs.readFileSync(file, "utf8").split("\n"); +const safeFiles = dir => { try { return fs.readdirSync(dir); } catch { return []; } }; + +for (const home of homes) { + if (!fs.existsSync(home)) { unreadableHome = true; continue; } + const stateDir = path.join(home, "state"); + const dataDir = path.join(home, "data"); + for (const name of safeFiles(stateDir).filter(name => name.endsWith(".meta"))) { + const task = name.slice(0, -5); + try { + const fields = Object.fromEntries(readLines(path.join(stateDir, name)).filter(line => line.includes("=")).map(line => [line.slice(0,line.indexOf("=")), line.slice(line.indexOf("=")+1)])); + if (fields.kind !== "secondmate") meta.push({task,home,worktree:fields.worktree || ""}); + } catch { unreadableHome = true; } + } + const backlog = path.join(dataDir, "backlog.md"); + if (fs.existsSync(backlog)) { + try { + for (const line of readLines(backlog)) { + const match = line.match(/^- \[x\] ([A-Za-z0-9._-]+)(?: |$)/); + if (match) closed.add(`${home}\0${match[1]}`); + } + } catch { unreadableHome = true; } + } + for (const name of safeFiles(stateDir).filter(name => name.endsWith(".lavish-sessions"))) { + try { + for (const line of readLines(path.join(stateDir,name)).filter(Boolean)) { + const row = JSON.parse(line); + if (!row.ended_at && row.key) ledgers.set(row.key, row); + } + } catch { unreadableHome = true; } + } + const pe = path.join(stateDir, "procevent"); + for (const name of safeFiles(pe).filter(name => name.startsWith("lavish-") && name.endsWith(".source"))) { + sources.add(name.slice(0,-7)); activePollRegistrations++; + } + const bindings = path.join(stateDir, "decision-bindings"); + for (const name of safeFiles(bindings).filter(name => name.startsWith("lavish-") && name.endsWith(".origin"))) decisions.add(name.slice(0,-7)); + const inbox = path.join(stateDir, "procevent-inbox"); + for (const name of safeFiles(inbox).filter(name => /^lavish-[^.]+\.[0-9]+\.result$/.test(name))) { + if (!fs.existsSync(path.join(inbox, name.replace(/\.result$/, ".handled")))) unacked.add(name.replace(/\.[0-9]+\.result$/, "")); + } +} + +const attached = new Map(); +if (process.env.ATTACHED_FILE) { + try { + for (const line of readLines(process.env.ATTACHED_FILE)) { + if (!line) continue; + const [key,kind="client"] = line.split("\t"); + if (key) attached.set(key, kind); + } + } catch (error) { fail(`cannot read attached-client evidence: ${error.message}`); } +} +try { + const ps = cp.execFileSync("ps", ["-axo", "command="], {encoding:"utf8"}); + for (const row of Object.values(state.sessions)) { + if (row?.file && ps.split("\n").some(line => line.includes("lavish-axi poll") && line.includes(row.file))) attached.set(row.key, "live-poll-process"); + } +} catch { unreadableHome = true; } + +const isUnder = (file, root) => file === root || file.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`); +const evidenceFor = row => { + const evidence = []; + let classification = "ambiguous"; + if (!row || !row.key || !row.file) return {classification,evidence:["malformed-registry-row"]}; + if (row.status === "ended") return {classification:"preserve", evidence:["historical-ended-registry-row"]}; + const exists = fs.existsSync(row.file); + if (!exists) return {classification:"ambiguous", evidence:["unsupported-by-current-Lavish","artifact-missing"]}; + const sid = sourceId(row.file); + 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 (sources.has(sid)) evidence.push("registered-process-event-source"); + if (decisions.has(sid)) evidence.push("open-decision-binding"); + if (attached.has(row.key)) evidence.push(`attached-${attached.get(row.key)}`); + const current = meta.find(owner => owner.worktree && isUnder(row.file, owner.worktree)); + if (current) evidence.push(`current-task:${current.task}`); + const ledger = ledgers.get(row.key); + if (ledger) { + const ledgerHome = ledger.home; + const live = meta.some(owner => owner.home === ledgerHome && owner.task === ledger.task_id); + if (live) evidence.push(`ledger-live-task:${ledger.task_id}`); + } + if (row.layout_warnings_pending || row.layout_warning_repair_open) evidence.push("unresolved-layout-warning-repair"); + if (evidence.length) return {classification:"preserve",evidence}; + + if (row.file.includes(`${path.sep}.treehouse${path.sep}`)) return {classification:"preserve",evidence:["retained-worktree-file"]}; + + let closedOwner = null; + if (ledger && closed.has(`${ledger.home}\0${ledger.task_id}`)) closedOwner = `${ledger.home}:${ledger.task_id}`; + if (!closedOwner) { + for (const home of homes) { + const dataRoot = path.join(home,"data"); + if (!isUnder(row.file,dataRoot)) continue; + const rel = path.relative(dataRoot,row.file); + const task = rel.split(path.sep)[0]; + if (task && closed.has(`${home}\0${task}`)) { closedOwner = `${home}:${task}`; break; } + } + } + if (closedOwner && row.status === "open") return {classification:"eligible",evidence:[`closed-task:${closedOwner}`,"existing-artifact","no-review-owner-or-client"]}; + if (unreadableHome) return {classification:"ambiguous",evidence:["ownership-incomplete-unreadable-home"]}; + 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,with_live_task:0,without_live_task:0,active_poll_registrations:activePollRegistrations,attached_clients:attached.size}; +const eligible = []; +for (const row of rows) { + if (["open","feedback","ended"].includes(row.status)) counts[row.status]++; + if (row.status !== "ended" && !fs.existsSync(row.file || "")) counts.missing_file++; + const live = meta.some(owner => owner.worktree && row.file && isUnder(row.file, owner.worktree)); + 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 (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} with_live_task=${counts.with_live_task} without_live_task=${counts.without_live_task}; live connections: attached_clients=${counts.attached_clients}; active_poll_registrations=${counts.active_poll_registrations}\n`); +} +if (process.env.FREEZE_FILE) { + const dir = path.dirname(process.env.FREEZE_FILE); + 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); +} +NODE +} + +cmd_audit() { + local freeze='' homes + while [ "$#" -gt 0 ]; do + case "$1" in + --freeze) [ "$#" -ge 2 ] || usage; freeze=$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" + 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" +} + +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}`);' +} + +cmd_apply() { + local candidate=${1-} batch=10 processed=0 line key file expected_status expected_updated current verdict homes + [ -n "$candidate" ] || usage + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --batch-size) [ "$#" -ge 2 ] || usage; batch=$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" + 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" + while IFS= read -r line; do + [ -n "$line" ] || continue + IFS=$'\t' read -r key file expected_status expected_updated </dev/null 2>&1 || die "lavish-axi is not installed" + lavish-axi 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)" + processed=$((processed + 1)) + if [ $((processed % batch)) -eq 0 ]; then printf 'batch-complete: processed=%s %s\n' "$processed" "$(count_registry)"; fi + done < "$candidate" + 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 "$@" ;; + apply) shift; cmd_apply "$@" ;; + -h|--help|help) usage ;; + *) usage ;; +esac diff --git a/bin/fm-lavish-session.sh b/bin/fm-lavish-session.sh new file mode 100755 index 00000000000..5ff5735d371 --- /dev/null +++ b/bin/fm-lavish-session.sh @@ -0,0 +1,284 @@ +#!/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-ephemeral +# 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}" + +die() { printf 'error: %s\n' "$1" >&2; exit 1; } +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"; } + +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 + ledger=$(ledger_path "$task") + mkdir -p "$STATE" || die "cannot create state directory: $STATE" + tmp=$(umask 077; mktemp "$STATE/.${task}.lavish-sessions.XXXXXX") \ + || die "cannot stage the Lavish ledger" + TASK_ID="$task" HOME_REAL="$(canonical_file "$FM_HOME")" ARTIFACT_REAL="$real" \ + DISPOSITION="$disposition" SESSION_JSON="$session_json" LEDGER="$ledger" node <<'NODE' > "$tmp" \ + || { rm -f "$tmp"; 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; + try { rows.push(JSON.parse(line)); } catch { console.error("existing Lavish ledger is malformed"); process.exit(1); } + } +} +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(), + 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"; die "cannot protect the Lavish ledger"; } + mv -f "$tmp" "$ledger" || { rm -f "$tmp"; die "cannot publish the Lavish ledger"; } +} + +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 meta task worktree matches=0 owner='' disposition='' + real=$(canonical_file "$artifact") + if [ -n "$explicit" ]; then + validate_task_id "$explicit" + owner=$explicit + fi + for meta in "$STATE"/*.meta; do + [ -f "$meta" ] && [ ! -L "$meta" ] || continue + task=${meta##*/}; task=${task%.meta} + [ -z "$owner" ] || [ "$task" = "$owner" ] || continue + worktree=$(sed -n 's/^worktree=//p' "$meta" | tail -1) + if [ -n "$worktree" ] && { [ "$real" = "$worktree" ] || [[ "$real" == "$worktree"/* ]]; }; then + owner=$task; disposition=ephemeral-worktree; matches=$((matches + 1)) + fi + done + if [ -n "$owner" ] && { [ "$real" = "$FM_HOME/data/$owner" ] || [[ "$real" == "$FM_HOME/data/$owner"/* ]]; }; then + disposition='durable-review' + matches=$((matches + 1)) + fi + [ -n "$owner" ] || die "cannot establish a task owner for Lavish artifact: $real" + [ -n "$disposition" ] || die "artifact is outside task $owner's worktree and durable data directory: $real" + [ "$matches" -eq 1 ] || die "Lavish artifact ownership is ambiguous for task $owner: $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 + ledger=$(ledger_path "$task") + tmp=$(umask 077; mktemp "$STATE/.${task}.lavish-sessions.XXXXXX") \ + || die "cannot stage the Lavish ledger" + KEY="$key" LEDGER="$ledger" node <<'NODE' > "$tmp" \ + || { rm -f "$tmp"; die "cannot record the ended Lavish session"; } +const fs = require("node:fs"); +const rows = fs.readFileSync(process.env.LEDGER, "utf8").trim().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" + die "cannot publish the ended Lavish ledger" + fi +} + +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-axi 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)" + mark_ended "$task" "$key" + printf 'ended: %s %s\n' "$key" "$real" +} + +ledger_rows() { + local task=$1 disposition=${2-} ledger + ledger=$(ledger_path "$task") + [ -f "$ledger" ] && [ ! -L "$ledger" ] || return 0 + 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.ended_at) continue; + if (process.env.DISPOSITION && row.disposition !== process.env.DISPOSITION) continue; + process.stdout.write(`${row.artifact}\t${row.key}\n`); +} +NODE +} + +cmd_end() { + local task=${1-} artifact=${2-} real row key + [ "$#" -eq 2 ] || usage + validate_task_id "$task" + real=$(canonical_file "$artifact") + row=$(ledger_rows "$task" | awk -F '\t' -v file="$real" '$1 == file { print; exit }') + [ -n "$row" ] || die "artifact is not an active recorded session for task $task: $real" + key=${row#*$'\t'} + end_recorded_file "$task" "$real" "$key" +} + +cmd_end_ephemeral() { + local task=${1-} rows real key + [ "$#" -eq 1 ] || usage + validate_task_id "$task" + rows=$(ledger_rows "$task" ephemeral-worktree) || die "cannot read the Lavish ledger for $task" + while IFS=$'\t' read -r real key; do + [ -n "$real" ] || continue + end_recorded_file "$task" "$real" "$key" || exit 1 + done </dev/null 2>&1 || die "lavish-axi is not installed" + lavish-axi "$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" + "$SCRIPT_DIR/fm-captain-hold.sh" bind "$new_id" "$origin" >/dev/null || exit 1 + 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 "$@" ;; + end) shift; cmd_end "$@" ;; + end-ephemeral) shift; cmd_end_ephemeral "$@" ;; + 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..fe08edc1a25 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, @@ -162,9 +163,13 @@ cmd_source_id() { } cmd_arm() { - local artifact=${1-} id real + local artifact=${1-} id real task= [ -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 @@ -176,6 +181,16 @@ cmd_arm() { # 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 + if [ "${FM_LAVISH_LEDGER_TEST_BYPASS:-0}" != 1 ]; then + if [ -n "$task" ]; then + "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$real" "$task" >/dev/null + else + "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$real" >/dev/null + fi || { + "$SCRIPT_DIR/fm-procevent.sh" retire "$id" >/dev/null 2>&1 || true + die "cannot record Lavish ownership for $real" + } + fi printf 'armed: %s\n' "$id" printf 'artifact: %s\n' "$real" } @@ -187,6 +202,13 @@ cmd_retire() { "$SCRIPT_DIR/fm-procevent.sh" retire "$id" } +cmd_retire_and_end() { + local task=${1-} artifact=${2-} + [ "$#" -eq 2 ] || usage + cmd_retire "$artifact" || exit 1 + "$SCRIPT_DIR/fm-lavish-session.sh" end "$task" "$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 @@ -680,6 +702,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..e2a08921b18 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2762,6 +2762,14 @@ fi # dedicated process-event and firstmate-home removal machinery further below, # not by task-worktree cleanup. if [ "$KIND" != secondmate ]; then + # Lavish's supported end command requires the source file to still exist. + # The ledger owner therefore closes and verifies every recorded ephemeral + # review before process reaping or worktree return can remove that file. + 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 + } conclude_task_no_mistakes_run "$WT" reap_task_worktree_processes worktree "$WT" "$TASK_TMP" fi diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 3423cc4acfc..19e951bd44e 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -28,6 +28,7 @@ set -u BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} TMP_ROOT=$(fm_test_tmproot fm-bootstrap-tests) +export FM_LAVISH_STATE_FILE="$TMP_ROOT/no-lavish-state.json" export FM_BACKEND_CMUX_BUNDLE_BIN="$TMP_ROOT/no-bundled-cmux" # Hermetic runtime-backend detection. These cases pin the backend per-home via @@ -44,7 +45,8 @@ unset TMUX TMUX_PANE HERDR_ENV HERDR_PANE_ID HERDR_SESSION HERDR_SOCKET_PATH \ make_fake_toolchain() { local dir=$1 fakebin fakebin=$(fm_fakebin "$dir") - fm_fake_exit0 "$fakebin" tmux node chrome-devtools-axi + fm_fake_exit0 "$fakebin" tmux chrome-devtools-axi + ln -s "$(command -v node)" "$fakebin/node" fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46 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" + 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;iboard\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 + +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" + +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" +mkdir -p "$(dirname "$CURRENT")" +printf x > "$CURRENT"; printf x > "$ELIGIBLE"; printf x > "$AMBIGUOUS"; printf x > "$FEEDBACK" +cat > "$AUDIT_HOME/state/current-task.meta" < "$AUDIT_HOME/data/backlog.md" +CURRENT="$CURRENT" ELIGIBLE="$ELIGIBLE" AMBIGUOUS="$AMBIGUOUS" MISSING="$MISSING" FEEDBACK="$FEEDBACK" 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], +]; +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:"2026-09-08T00:00:00.000Z"}; +process.stdout.write(JSON.stringify({sessions}, null, 2)); +NODE + +FREEZE="$TMP_ROOT/candidates.jsonl" +OUT=$(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" +[ "$(wc -l < "$FREEZE" | tr -d ' ')" = 1 ] || fail "freeze did not contain exactly the eligible session" +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" + +SUMMARY=$(FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" "$ROOT/bin/fm-lavish-audit.sh" summary) +assert_contains "$SUMMARY" 'total=5' "summary counts total registry rows" +assert_contains "$SUMMARY" 'open=3' "summary counts open registry rows after apply" +assert_contains "$SUMMARY" 'feedback=1' "summary counts feedback rows" +assert_contains "$SUMMARY" 'ended=1' "summary counts ended rows" +assert_contains "$SUMMARY" 'missing_file=1' "summary counts open missing-file rows" +pass "summary distinguishes registry counts from live connections" + +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..2ea83eef0e5 100755 --- a/tests/fm-procevent-lavish-ack.test.sh +++ b/tests/fm-procevent-lavish-ack.test.sh @@ -2,6 +2,7 @@ # Behavior tests for Lavish delivery acknowledgement through the real # process-event capture path and a protocol-faithful fake lavish-axi. set -u +export FM_LAVISH_LEDGER_TEST_BYPASS=1 # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" diff --git a/tests/fm-procevent-lavish-live-e2e.test.sh b/tests/fm-procevent-lavish-live-e2e.test.sh index bfe161f9028..28b58e3d462 100755 --- a/tests/fm-procevent-lavish-live-e2e.test.sh +++ b/tests/fm-procevent-lavish-live-e2e.test.sh @@ -4,6 +4,7 @@ # scratch directory, and its server uses an isolated ephemeral port. It never # invokes the globally installed lavish-axi or the shared server on port 4387. set -u +export FM_LAVISH_LEDGER_TEST_BYPASS=1 if [ "${FM_LAVISH_LIVE_E2E:-0}" != 1 ]; then echo "skip: set FM_LAVISH_LIVE_E2E=1 to run the patched Lavish capture/ACK regression" diff --git a/tests/fm-procevent.test.sh b/tests/fm-procevent.test.sh index c28262ddf74..74c7386bd02 100755 --- a/tests/fm-procevent.test.sh +++ b/tests/fm-procevent.test.sh @@ -11,6 +11,7 @@ # Legacy responses without delivery_id keep the older source-side loss window, # while the runner's own capture-before-announcement guarantee applies to both. set -u +export FM_LAVISH_LEDGER_TEST_BYPASS=1 # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" From 1415420b8ba4559c9c9e593b2f217af40713509e Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 8 Sep 2026 08:15:20 +0800 Subject: [PATCH 02/12] fix: fail closed on live lavish review owners --- bin/fm-lavish-audit.sh | 23 ++++++++++++---- bin/fm-lavish-session.sh | 49 +++++++++++++++++++++++++++++---- tests/fm-lavish-session.test.sh | 19 +++++++++++-- 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/bin/fm-lavish-audit.sh b/bin/fm-lavish-audit.sh index 266b025648e..633de9ea49d 100755 --- a/bin/fm-lavish-audit.sh +++ b/bin/fm-lavish-audit.sh @@ -47,7 +47,8 @@ make_homes_file() { run_audit_node() { local mode=$1 homes_file=$2 freeze=${3-} AUDIT_MODE="$mode" HOMES_FILE="$homes_file" FREEZE_FILE="$freeze" \ - LAVISH_STATE_FILE="$LAVISH_STATE_FILE" ATTACHED_FILE="${FM_LAVISH_ATTACHED_KEYS_FILE:-}" node <<'NODE' + LAVISH_STATE_FILE="$LAVISH_STATE_FILE" ATTACHED_FILE="${FM_LAVISH_ATTACHED_KEYS_FILE:-}" \ + LSOF_FILE="${FM_LAVISH_LSOF_FILE:-}" LAVISH_PORT="${LAVISH_AXI_PORT:-4387}" node <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const crypto = require("node:crypto"); @@ -121,6 +122,13 @@ if (process.env.ATTACHED_FILE) { } } catch (error) { fail(`cannot read attached-client evidence: ${error.message}`); } } +let browserConnections = 0; +try { + const lsof = process.env.LSOF_FILE + ? fs.readFileSync(process.env.LSOF_FILE,"utf8") + : cp.execFileSync("lsof", ["-nP", `-iTCP:${process.env.LAVISH_PORT}`, "-sTCP:ESTABLISHED"], {encoding:"utf8"}); + browserConnections = lsof.split("\n").filter(line => /^(Google|Chromium|Chrome)\s/.test(line)).length; +} catch { unreadableHome = true; } try { const ps = cp.execFileSync("ps", ["-axo", "command="], {encoding:"utf8"}); for (const row of Object.values(state.sessions)) { @@ -150,7 +158,7 @@ const evidenceFor = row => { const live = meta.some(owner => owner.home === ledgerHome && owner.task === ledger.task_id); if (live) evidence.push(`ledger-live-task:${ledger.task_id}`); } - if (row.layout_warnings_pending || row.layout_warning_repair_open) evidence.push("unresolved-layout-warning-repair"); + if ((row.layout_warnings || []).length > 0 || row.layout_warnings_pending || row.layout_warning_repair_open) evidence.push("unresolved-layout-warning-repair"); if (evidence.length) return {classification:"preserve",evidence}; if (row.file.includes(`${path.sep}.treehouse${path.sep}`)) return {classification:"preserve",evidence:["retained-worktree-file"]}; @@ -166,13 +174,15 @@ const evidenceFor = row => { if (task && closed.has(`${home}\0${task}`)) { closedOwner = `${home}:${task}`; break; } } } + if (closedOwner && unreadableHome) return {classification:"ambiguous",evidence:[`closed-task:${closedOwner}`,"ownership-incomplete-unreadable-home"]}; + if (closedOwner && row.status === "open" && browserConnections > 0) return {classification:"ambiguous",evidence:[`closed-task:${closedOwner}`,`unmapped-browser-connections:${browserConnections}`]}; if (closedOwner && row.status === "open") return {classification:"eligible",evidence:[`closed-task:${closedOwner}`,"existing-artifact","no-review-owner-or-client"]}; if (unreadableHome) return {classification:"ambiguous",evidence:["ownership-incomplete-unreadable-home"]}; 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,with_live_task:0,without_live_task:0,active_poll_registrations:activePollRegistrations,attached_clients:attached.size}; +const counts = {total:rows.length,open:0,feedback:0,ended:0,missing_file: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 (["open","feedback","ended"].includes(row.status)) counts[row.status]++; @@ -184,7 +194,7 @@ for (const row of rows) { if (process.env.AUDIT_MODE === "audit") process.stdout.write(`${verdict.classification}\t${row.key}\t${verdict.evidence.join(",")}\t${row.file || ""}\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} with_live_task=${counts.with_live_task} without_live_task=${counts.without_live_task}; live connections: attached_clients=${counts.attached_clients}; active_poll_registrations=${counts.active_poll_registrations}\n`); + process.stdout.write(`Lavish registry rows: total=${counts.total} open=${counts.open} feedback=${counts.feedback} ended=${counts.ended} missing_file=${counts.missing_file} 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); @@ -230,7 +240,7 @@ count_registry() { } cmd_apply() { - local candidate=${1-} batch=10 processed=0 line key file expected_status expected_updated current verdict homes + local candidate=${1-} batch=10 processed=0 line key file expected_status expected_updated current verdict homes audit_output [ -n "$candidate" ] || usage shift while [ "$#" -gt 0 ]; do @@ -256,7 +266,8 @@ EOF || die "frozen candidate key is absent: $key" [ "$current" = "$file"$'\t'"$expected_status"$'\t'"$expected_updated" ] \ || die "frozen candidate changed since audit: $key" - verdict=$(run_audit_node audit "$homes" | awk -F '\t' -v key="$key" '$2 == key {print $1; exit}') + audit_output=$(run_audit_node audit "$homes") || die "could not reclassify frozen candidate: $key" + verdict=$(printf '%s\n' "$audit_output" | awk -F '\t' -v key="$key" '$2 == key {print $1; exit}') [ "$verdict" = eligible ] || die "frozen candidate is no longer eligible: $key ($verdict)" [ -f "$file" ] && [ ! -L "$file" ] || die "unsupported-by-current-Lavish: $key $file" command -v lavish-axi >/dev/null 2>&1 || die "lavish-axi is not installed" diff --git a/bin/fm-lavish-session.sh b/bin/fm-lavish-session.sh index 5ff5735d371..0c7dba332b7 100755 --- a/bin/fm-lavish-session.sh +++ b/bin/fm-lavish-session.sh @@ -192,28 +192,61 @@ for (const line of fs.readFileSync(process.env.LEDGER, "utf8").split("\n")) { const row = JSON.parse(line); if (row.ended_at) continue; if (process.env.DISPOSITION && row.disposition !== process.env.DISPOSITION) continue; - process.stdout.write(`${row.artifact}\t${row.key}\n`); + process.stdout.write(`${row.artifact}\t${row.key}\t${row.disposition}\n`); } NODE } +guard_durable_end() { + local task=$1 real=$2 key=$3 source_id result hold_status=0 session_json + source_id=$("$SCRIPT_DIR/fm-procevent-lavish.sh" source-id "$real") || return 1 + [ ! -e "$STATE/procevent/$source_id.source" ] \ + || die "durable Lavish review still has a registered process-event source: $source_id" + [ ! -e "$STATE/decision-bindings/$source_id.origin" ] \ + || die "durable Lavish review still has an open decision binding: $source_id" + for result in "$STATE/procevent-inbox/$source_id".*.result; do + [ -e "$result" ] || continue + [ -e "${result%.result}.handled" ] \ + || die "durable Lavish review still has an unacknowledged delivery: $source_id" + 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 (Number(row.pending_prompts || 0) > 0 || (row.prompts || []).length > 0 || (row.layout_warnings || []).length > 0) process.exit(1); +NODE + if [ -f "$FM_HOME/data/backlog.md" ] && command -v tasks-axi >/dev/null 2>&1; then + FM_HOME="$FM_HOME" "$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 + fi +} + cmd_end() { - local task=${1-} artifact=${2-} real row key + local task=${1-} artifact=${2-} real row key disposition [ "$#" -eq 2 ] || usage validate_task_id "$task" real=$(canonical_file "$artifact") row=$(ledger_rows "$task" | awk -F '\t' -v file="$real" '$1 == file { print; exit }') [ -n "$row" ] || die "artifact is not an active recorded session for task $task: $real" - key=${row#*$'\t'} + key=${row#*$'\t'}; key=${key%%$'\t'*} + disposition=${row##*$'\t'} + if [ "$disposition" = durable-review ]; then + guard_durable_end "$task" "$real" "$key" + fi end_recorded_file "$task" "$real" "$key" } cmd_end_ephemeral() { - local task=${1-} rows real key + local task=${1-} rows real key disposition [ "$#" -eq 1 ] || usage validate_task_id "$task" rows=$(ledger_rows "$task" ephemeral-worktree) || die "cannot read the Lavish ledger for $task" - while IFS=$'\t' read -r real key; do + while IFS=$'\t' read -r real key disposition; do [ -n "$real" ] || continue end_recorded_file "$task" "$real" "$key" || exit 1 done </dev/null || exit 1 + 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 diff --git a/tests/fm-lavish-session.test.sh b/tests/fm-lavish-session.test.sh index 10e5fe85635..6c2835797cd 100755 --- a/tests/fm-lavish-session.test.sh +++ b/tests/fm-lavish-session.test.sh @@ -84,8 +84,21 @@ jq -s -e 'any(.[]; .key == "durable" and .disposition == "durable-review")' "$LE || 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" + AUDIT_HOME="$TMP_ROOT/audit-home" AUDIT_STATE="$TMP_ROOT/audit-lavish" +LSOF_FILE="$TMP_ROOT/empty-lsof" +: > "$LSOF_FILE" 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" @@ -114,7 +127,7 @@ process.stdout.write(JSON.stringify({sessions}, null, 2)); NODE FREEZE="$TMP_ROOT/candidates.jsonl" -OUT=$(FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" \ +OUT=$(FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" FM_LAVISH_LSOF_FILE="$LSOF_FILE" \ "$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" @@ -125,7 +138,7 @@ assert_contains "$OUT" $'preserve\tfeedback\t' "pending feedback is preserved" 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" \ +PATH="$FAKE_BIN:$PATH" FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" FM_LAVISH_LSOF_FILE="$LSOF_FILE" \ "$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" @@ -133,7 +146,7 @@ PATH="$FAKE_BIN:$PATH" FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" || fail "apply changed an ambiguous session" pass "apply ends only frozen eligible sessions and verifies the transition" -SUMMARY=$(FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" "$ROOT/bin/fm-lavish-audit.sh" summary) +SUMMARY=$(FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" FM_LAVISH_LSOF_FILE="$LSOF_FILE" "$ROOT/bin/fm-lavish-audit.sh" summary) assert_contains "$SUMMARY" 'total=5' "summary counts total registry rows" assert_contains "$SUMMARY" 'open=3' "summary counts open registry rows after apply" assert_contains "$SUMMARY" 'feedback=1' "summary counts feedback rows" From baaea589d284e372f6448da05823a4ec65bce639 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 8 Sep 2026 08:33:41 +0800 Subject: [PATCH 03/12] feat: expire idle lavish sessions --- bin/fm-lavish-audit.sh | 82 +++++++++++++++++++++++++-------- bin/fm-lavish-session.sh | 1 + docs/configuration.md | 5 ++ tests/fm-lavish-session.test.sh | 29 ++++++++---- 4 files changed, 90 insertions(+), 27 deletions(-) diff --git a/bin/fm-lavish-audit.sh b/bin/fm-lavish-audit.sh index 633de9ea49d..c958cc6b2a0 100755 --- a/bin/fm-lavish-audit.sh +++ b/bin/fm-lavish-audit.sh @@ -2,9 +2,12 @@ # Audit the Lavish registry against Firstmate lifecycle ownership without mutation. # # Usage: -# fm-lavish-audit.sh [audit] [--freeze ] +# fm-lavish-audit.sh [audit] [--freeze ] [--expiry-hours ] +# [--preserve-paths ] [--ignore-unmapped-browser] # fm-lavish-audit.sh summary # fm-lavish-audit.sh apply [--batch-size <1..50>] +# [--expiry-hours ] [--preserve-paths ] +# [--ignore-unmapped-browser] # # audit is the default and classifies every registry row as preserve, eligible, # or ambiguous with evidence. @@ -22,6 +25,10 @@ # 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)" @@ -48,7 +55,9 @@ run_audit_node() { local mode=$1 homes_file=$2 freeze=${3-} AUDIT_MODE="$mode" HOMES_FILE="$homes_file" FREEZE_FILE="$freeze" \ LAVISH_STATE_FILE="$LAVISH_STATE_FILE" ATTACHED_FILE="${FM_LAVISH_ATTACHED_KEYS_FILE:-}" \ - LSOF_FILE="${FM_LAVISH_LSOF_FILE:-}" LAVISH_PORT="${LAVISH_AXI_PORT:-4387}" node <<'NODE' + LSOF_FILE="${FM_LAVISH_LSOF_FILE:-}" LAVISH_PORT="${LAVISH_AXI_PORT:-4387}" \ + EXPIRY_HOURS="${FM_LAVISH_IDLE_EXPIRY_HOURS:-48}" PRESERVE_PATHS_FILE="${FM_LAVISH_PRESERVE_PATHS_FILE:-}" \ + IGNORE_UNMAPPED_BROWSER="${FM_LAVISH_IGNORE_UNMAPPED_BROWSER:-0}" node <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const crypto = require("node:crypto"); @@ -62,6 +71,7 @@ if (!state.sessions || typeof state.sessions !== "object" || Array.isArray(state const homes = fs.readFileSync(process.env.HOMES_FILE, "utf8").split("\n").filter(Boolean); const meta = []; const closed = new Set(); +const held = new Map(); const ledgers = new Map(); const sources = new Set(); const decisions = new Set(); @@ -89,6 +99,9 @@ for (const home of homes) { for (const line of readLines(backlog)) { const match = line.match(/^- \[x\] ([A-Za-z0-9._-]+)(?: |$)/); if (match) closed.add(`${home}\0${match[1]}`); + const taskMatch = line.match(/^- \[[ x]\] ([A-Za-z0-9._-]+)(?: |$)/); + const holdMatch = line.match(/\(hold-kind: ([A-Za-z0-9._-]+)\)/); + if (taskMatch && holdMatch) held.set(`${home}\0${taskMatch[1]}`, holdMatch[1]); } } catch { unreadableHome = true; } } @@ -113,6 +126,13 @@ for (const home of homes) { } const attached = new Map(); +const preservePaths = new Set(); +if (process.env.PRESERVE_PATHS_FILE) { + try { for (const line of readLines(process.env.PRESERVE_PATHS_FILE)) if (line) preservePaths.add(line); } + catch (error) { fail(`cannot read preserve-path evidence: ${error.message}`); } +} +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) { try { for (const line of readLines(process.env.ATTACHED_FILE)) { @@ -137,6 +157,19 @@ try { } catch { unreadableHome = true; } const isUnder = (file, root) => file === root || file.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`); +const lastActivityFor = (row, ledger) => { + const values = [row.updated_at, ledger?.last_polled_at].filter(Boolean).map(value => Date.parse(value)).filter(Number.isFinite); + return values.length ? Math.max(...values) : NaN; +}; +const dataOwnerFor = file => { + for (const home of homes) { + const dataRoot = path.join(home,"data"); + if (!isUnder(file,dataRoot)) continue; + const task = path.relative(dataRoot,file).split(path.sep)[0]; + if (task) return {home,task}; + } + return null; +}; const evidenceFor = row => { const evidence = []; let classification = "ambiguous"; @@ -144,6 +177,7 @@ const evidenceFor = row => { if (row.status === "ended") return {classification:"preserve", evidence:["historical-ended-registry-row"]}; const exists = fs.existsSync(row.file); if (!exists) return {classification:"ambiguous", evidence:["unsupported-by-current-Lavish","artifact-missing"]}; + 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); 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"); @@ -158,35 +192,38 @@ const evidenceFor = row => { const live = meta.some(owner => owner.home === ledgerHome && owner.task === ledger.task_id); if (live) evidence.push(`ledger-live-task:${ledger.task_id}`); } + const dataOwner = dataOwnerFor(row.file); + const heldKey = ledger ? `${ledger.home}\0${ledger.task_id}` : dataOwner ? `${dataOwner.home}\0${dataOwner.task}` : ""; + if (heldKey && held.has(heldKey)) evidence.push(`retained-backlog-hold:${held.get(heldKey)}`); if ((row.layout_warnings || []).length > 0 || row.layout_warnings_pending || row.layout_warning_repair_open) evidence.push("unresolved-layout-warning-repair"); if (evidence.length) return {classification:"preserve",evidence}; if (row.file.includes(`${path.sep}.treehouse${path.sep}`)) return {classification:"preserve",evidence:["retained-worktree-file"]}; + const lastActivity = lastActivityFor(row, ledger); + const expired = Number.isFinite(lastActivity) && Date.now() - lastActivity >= expiryHours * 60 * 60 * 1000; + if (expired && browserConnections > 0 && process.env.IGNORE_UNMAPPED_BROWSER !== "1") return {classification:"ambiguous",evidence:[`idle-expired:${expiryHours}h`,`unmapped-browser-connections:${browserConnections}`]}; + if (expired) return {classification:"eligible",evidence:[`idle-expired:${expiryHours}h`,"existing-artifact","no-review-owner-or-client"]}; + let closedOwner = null; if (ledger && closed.has(`${ledger.home}\0${ledger.task_id}`)) closedOwner = `${ledger.home}:${ledger.task_id}`; - if (!closedOwner) { - for (const home of homes) { - const dataRoot = path.join(home,"data"); - if (!isUnder(row.file,dataRoot)) continue; - const rel = path.relative(dataRoot,row.file); - const task = rel.split(path.sep)[0]; - if (task && closed.has(`${home}\0${task}`)) { closedOwner = `${home}:${task}`; break; } - } - } + if (!closedOwner && dataOwner && closed.has(`${dataOwner.home}\0${dataOwner.task}`)) closedOwner = `${dataOwner.home}:${dataOwner.task}`; if (closedOwner && unreadableHome) return {classification:"ambiguous",evidence:[`closed-task:${closedOwner}`,"ownership-incomplete-unreadable-home"]}; - if (closedOwner && row.status === "open" && browserConnections > 0) return {classification:"ambiguous",evidence:[`closed-task:${closedOwner}`,`unmapped-browser-connections:${browserConnections}`]}; + if (closedOwner && row.status === "open" && browserConnections > 0 && process.env.IGNORE_UNMAPPED_BROWSER !== "1") return {classification:"ambiguous",evidence:[`closed-task:${closedOwner}`,`unmapped-browser-connections:${browserConnections}`]}; if (closedOwner && row.status === "open") return {classification:"eligible",evidence:[`closed-task:${closedOwner}`,"existing-artifact","no-review-owner-or-client"]}; if (unreadableHome) return {classification:"ambiguous",evidence:["ownership-incomplete-unreadable-home"]}; 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,with_live_task:0,without_live_task:0,active_poll_registrations:activePollRegistrations,attached_clients:attached.size,unmapped_browser_connections:browserConnections}; +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 (["open","feedback","ended"].includes(row.status)) counts[row.status]++; if (row.status !== "ended" && !fs.existsSync(row.file || "")) counts.missing_file++; + const ledger = ledgers.get(row.key); + const lastActivity = lastActivityFor(row, ledger); + if (row.status === "open" && Number.isFinite(lastActivity) && Date.now() - lastActivity >= expiryHours * 60 * 60 * 1000) counts.past_expiry++; const live = meta.some(owner => owner.worktree && row.file && isUnder(row.file, owner.worktree)); if (row.status !== "ended") counts[live ? "with_live_task" : "without_live_task"]++; const verdict = evidenceFor(row); @@ -194,7 +231,7 @@ for (const row of rows) { if (process.env.AUDIT_MODE === "audit") process.stdout.write(`${verdict.classification}\t${row.key}\t${verdict.evidence.join(",")}\t${row.file || ""}\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} 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`); + 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); @@ -207,10 +244,13 @@ NODE } cmd_audit() { - local freeze='' homes + local freeze='' homes expiry=48 preserve_paths='' ignore_browser=0 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 ;; + --ignore-unmapped-browser) ignore_browser=1; shift ;; *) usage ;; esac done @@ -218,7 +258,8 @@ cmd_audit() { # 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 audit "$homes" "$freeze" + FM_LAVISH_IDLE_EXPIRY_HOURS="$expiry" FM_LAVISH_PRESERVE_PATHS_FILE="$preserve_paths" \ + FM_LAVISH_IGNORE_UNMAPPED_BROWSER="$ignore_browser" run_audit_node audit "$homes" "$freeze" } cmd_summary() { @@ -240,12 +281,15 @@ count_registry() { } cmd_apply() { - local candidate=${1-} batch=10 processed=0 line key file expected_status expected_updated current verdict homes audit_output + local candidate=${1-} batch=10 processed=0 line key file expected_status expected_updated current verdict homes audit_output expiry=48 preserve_paths='' ignore_browser=0 [ -n "$candidate" ] || usage shift while [ "$#" -gt 0 ]; do case "$1" in --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 ;; + --ignore-unmapped-browser) ignore_browser=1; shift ;; *) usage ;; esac done @@ -266,7 +310,9 @@ EOF || die "frozen candidate key is absent: $key" [ "$current" = "$file"$'\t'"$expected_status"$'\t'"$expected_updated" ] \ || die "frozen candidate changed since audit: $key" - audit_output=$(run_audit_node audit "$homes") || die "could not reclassify frozen candidate: $key" + audit_output=$(FM_LAVISH_IDLE_EXPIRY_HOURS="$expiry" FM_LAVISH_PRESERVE_PATHS_FILE="$preserve_paths" \ + FM_LAVISH_IGNORE_UNMAPPED_BROWSER="$ignore_browser" run_audit_node audit "$homes") \ + || die "could not reclassify frozen candidate: $key" verdict=$(printf '%s\n' "$audit_output" | awk -F '\t' -v key="$key" '$2 == key {print $1; exit}') [ "$verdict" = eligible ] || die "frozen candidate is no longer eligible: $key ($verdict)" [ -f "$file" ] && [ ! -L "$file" ] || die "unsupported-by-current-Lavish: $key $file" diff --git a/bin/fm-lavish-session.sh b/bin/fm-lavish-session.sh index 0c7dba332b7..0d36ab75efd 100755 --- a/bin/fm-lavish-session.sh +++ b/bin/fm-lavish-session.sh @@ -85,6 +85,7 @@ const row = { 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); diff --git a/docs/configuration.md b/docs/configuration.md index 08ddb298b15..1e3c2eab810 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -716,6 +716,11 @@ 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. + 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/tests/fm-lavish-session.test.sh b/tests/fm-lavish-session.test.sh index 6c2835797cd..f89e2fdc73b 100755 --- a/tests/fm-lavish-session.test.sh +++ b/tests/fm-lavish-session.test.sh @@ -105,14 +105,18 @@ 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" -mkdir -p "$(dirname "$CURRENT")" -printf x > "$CURRENT"; printf x > "$ELIGIBLE"; printf x > "$AMBIGUOUS"; printf x > "$FEEDBACK" +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" node <<'NODE' > "$AUDIT_STATE/state.json" +printf '%s\n' '- [x] closed-task - closed (repo: example) (kind: task)' '- [x] held-task - retained review (repo: example) (kind: task) (hold: keep) (hold-kind: parked)' > "$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], @@ -120,9 +124,12 @@ const rows = [ ["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:"2026-09-08T00:00:00.000Z"}; +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 @@ -134,7 +141,10 @@ assert_contains "$OUT" $'eligible\teligible\t' "positively closed task is eligib 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" -[ "$(wc -l < "$FREEZE" | tr -d ' ')" = 1 ] || fail "freeze did not contain exactly the eligible session" +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" @@ -147,11 +157,12 @@ PATH="$FAKE_BIN:$PATH" FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" pass "apply ends only frozen eligible sessions and verifies the transition" SUMMARY=$(FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" FM_LAVISH_LSOF_FILE="$LSOF_FILE" "$ROOT/bin/fm-lavish-audit.sh" summary) -assert_contains "$SUMMARY" 'total=5' "summary counts total registry rows" -assert_contains "$SUMMARY" 'open=3' "summary counts open registry rows after apply" +assert_contains "$SUMMARY" 'total=8' "summary counts total registry rows" +assert_contains "$SUMMARY" 'open=5' "summary counts open registry rows after apply" assert_contains "$SUMMARY" 'feedback=1' "summary counts feedback rows" -assert_contains "$SUMMARY" 'ended=1' "summary counts ended rows" +assert_contains "$SUMMARY" 'ended=2' "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" fm_test_cleanup From 83ae237fa67b21191aa4d6f2653dcc53c1110f3f Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 8 Sep 2026 11:05:19 +0800 Subject: [PATCH 04/12] no-mistakes(review): Harden Lavish ownership, audit, and teardown lifecycle --- .agents/skills/process-event-sources/SKILL.md | 2 + .gitignore | 5 +- bin/fm-bearings-board.sh | 7 +- bin/fm-lavish-audit.sh | 585 ++++++++++++++---- bin/fm-lavish-session.sh | 271 ++++++-- bin/fm-procevent-lavish.sh | 33 +- bin/fm-teardown.sh | 29 + .../authorized-2026-09-08.json | 27 + data/fm-lavish-session-prune-f1/report.md | 87 +++ docs/configuration.md | 2 + tests/fm-bearings-board.test.sh | 35 +- 11 files changed, 898 insertions(+), 185 deletions(-) create mode 100644 data/fm-lavish-session-prune-f1/authorized-2026-09-08.json create mode 100644 data/fm-lavish-session-prune-f1/report.md diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index 5c516d2b3bb..5e18af12229 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -31,8 +31,10 @@ 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: diff --git a/.gitignore b/.gitignore index dd0a8f1df19..93a64d2207b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ projects/ state/ -data/ +data/* +!data/fm-lavish-session-prune-f1/ +!data/fm-lavish-session-prune-f1/report.md +!data/fm-lavish-session-prune-f1/authorized-2026-09-08.json scratchpad* .no-mistakes/ .lavish/ diff --git a/bin/fm-bearings-board.sh b/bin/fm-bearings-board.sh index cff3cfb69cc..c33360b6c20 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__' @@ -67,6 +68,8 @@ fail() { } board_path() { printf '%s/.lavish/bearings-board.html\n' "$FM_HOME"; } +lavish_state_dir() { printf '%s\n' "${LAVISH_STATE_FILE%/*}"; } +lavish_cli() { LAVISH_AXI_STATE_DIR="$(lavish_state_dir)" command lavish-axi "$@"; } validate_payload() { # jq -e --arg schema "$BOARD_SCHEMA" ' @@ -178,7 +181,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") \ @@ -190,7 +193,7 @@ command_build() { if "$SCRIPT_DIR/fm-procevent.sh" list | awk 'NR > 1 { print $1 }' | grep -Fxq "$sid"; then 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-lavish-audit.sh b/bin/fm-lavish-audit.sh index c958cc6b2a0..1a40eff1dd8 100755 --- a/bin/fm-lavish-audit.sh +++ b/bin/fm-lavish-audit.sh @@ -3,11 +3,11 @@ # # Usage: # fm-lavish-audit.sh [audit] [--freeze ] [--expiry-hours ] -# [--preserve-paths ] [--ignore-unmapped-browser] +# [--preserve-paths ] # fm-lavish-audit.sh summary -# fm-lavish-audit.sh apply [--batch-size <1..50>] -# [--expiry-hours ] [--preserve-paths ] -# [--ignore-unmapped-browser] +# 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. @@ -16,10 +16,12 @@ # 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. +# and it never edits Lavish state.json directly. # -# Ownership is read across this FM_HOME and every local home registered in its +# 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 @@ -35,20 +37,43 @@ 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" die() { printf 'error: %s\n' "$1" >&2; exit 1; } usage() { sed -n '2,/^set -u$/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; exit 2; } +lavish_state_dir() { printf '%s\n' "${LAVISH_STATE_FILE%/*}"; } +lavish_cli() { LAVISH_AXI_STATE_DIR="$(lavish_state_dir)" command lavish-axi "$@"; } + make_homes_file() { - local out=$1 registry="$FM_HOME/data/secondmates.md" home - : > "$out" - printf '%s\n' "$FM_HOME" >> "$out" - if [ -f "$registry" ] && [ ! -L "$registry" ]; then - sed -n 's/.*(home: \([^;)]*\).*/\1/p' "$registry" | while IFS= read -r home; do - [ -n "$home" ] && printf '%s\n' "$home" - done >> "$out" + 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" && mv "$out.unique" "$out" + awk '!seen[$0]++' "$out" > "$out.unique" \ + || die "cannot deduplicate home inventory" + mv -f "$out.unique" "$out" || die "cannot publish home inventory" } run_audit_node() { @@ -57,18 +82,24 @@ run_audit_node() { LAVISH_STATE_FILE="$LAVISH_STATE_FILE" ATTACHED_FILE="${FM_LAVISH_ATTACHED_KEYS_FILE:-}" \ LSOF_FILE="${FM_LAVISH_LSOF_FILE:-}" LAVISH_PORT="${LAVISH_AXI_PORT:-4387}" \ EXPIRY_HOURS="${FM_LAVISH_IDLE_EXPIRY_HOURS:-48}" PRESERVE_PATHS_FILE="${FM_LAVISH_PRESERVE_PATHS_FILE:-}" \ - IGNORE_UNMAPPED_BROWSER="${FM_LAVISH_IGNORE_UNMAPPED_BROWSER:-0}" node <<'NODE' + 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 (!state.sessions || typeof state.sessions !== "object" || Array.isArray(state.sessions)) fail("Lavish state has no session registry object"); -const homes = fs.readFileSync(process.env.HOMES_FILE, "utf8").split("\n").filter(Boolean); +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(); @@ -76,181 +107,374 @@ const ledgers = new Map(); const sources = new Set(); const decisions = new Set(); const unacked = new Set(); +const attached = 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 sourceId = file => `lavish-${crypto.createHash("sha256").update(file).digest("hex").slice(0,16)}`; -const readLines = file => fs.readFileSync(file, "utf8").split("\n"); -const safeFiles = dir => { try { return fs.readdirSync(dir); } catch { return []; } }; -for (const home of homes) { - if (!fs.existsSync(home)) { unreadableHome = true; continue; } +const listDir = (home, dir, label) => { + 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") 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"); - for (const name of safeFiles(stateDir).filter(name => name.endsWith(".meta"))) { + const stateNames = listDir(home, stateDir, "state directory"); + listDir(home, dataDir, "data directory"); + + 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 = Object.fromEntries(readLines(path.join(stateDir, name)).filter(line => line.includes("=")).map(line => [line.slice(0,line.indexOf("=")), line.slice(line.indexOf("=")+1)])); - if (fields.kind !== "secondmate") meta.push({task,home,worktree:fields.worktree || ""}); - } catch { unreadableHome = true; } + 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"); + 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"); - if (fs.existsSync(backlog)) { - try { - for (const line of readLines(backlog)) { - const match = line.match(/^- \[x\] ([A-Za-z0-9._-]+)(?: |$)/); - if (match) closed.add(`${home}\0${match[1]}`); - const taskMatch = line.match(/^- \[[ x]\] ([A-Za-z0-9._-]+)(?: |$)/); - const holdMatch = line.match(/\(hold-kind: ([A-Za-z0-9._-]+)\)/); - if (taskMatch && holdMatch) held.set(`${home}\0${taskMatch[1]}`, holdMatch[1]); - } - } catch { unreadableHome = true; } + 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 safeFiles(stateDir).filter(name => name.endsWith(".lavish-sessions"))) { - try { - for (const line of readLines(path.join(stateDir,name)).filter(Boolean)) { + + 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 (!row.ended_at && row.key) ledgers.set(row.key, row); + 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); } - } catch { unreadableHome = true; } + } } - const pe = path.join(stateDir, "procevent"); - for (const name of safeFiles(pe).filter(name => name.startsWith("lavish-") && name.endsWith(".source"))) { - sources.add(name.slice(0,-7)); activePollRegistrations++; + + 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}`); + sources.add(name.slice(0, -7)); + activePollRegistrations++; } - const bindings = path.join(stateDir, "decision-bindings"); - for (const name of safeFiles(bindings).filter(name => name.startsWith("lavish-") && name.endsWith(".origin"))) decisions.add(name.slice(0,-7)); + + 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 safeFiles(inbox).filter(name => /^lavish-[^.]+\.[0-9]+\.result$/.test(name))) { - if (!fs.existsSync(path.join(inbox, name.replace(/\.result$/, ".handled")))) unacked.add(name.replace(/\.[0-9]+\.result$/, "")); + 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}`); + } } } -const attached = new Map(); -const preservePaths = new Set(); if (process.env.PRESERVE_PATHS_FILE) { - try { for (const line of readLines(process.env.PRESERVE_PATHS_FILE)) if (line) preservePaths.add(line); } - catch (error) { fail(`cannot read preserve-path evidence: ${error.message}`); } + 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) { - try { - for (const line of readLines(process.env.ATTACHED_FILE)) { - if (!line) continue; - const [key,kind="client"] = line.split("\t"); - if (key) attached.set(key, kind); - } - } catch (error) { fail(`cannot read attached-client evidence: ${error.message}`); } + 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 { - const lsof = process.env.LSOF_FILE - ? fs.readFileSync(process.env.LSOF_FILE,"utf8") - : cp.execFileSync("lsof", ["-nP", `-iTCP:${process.env.LAVISH_PORT}`, "-sTCP:ESTABLISHED"], {encoding:"utf8"}); + let lsof; + if (process.env.LSOF_FILE) { + lsof = readInventoryFile("runtime", process.env.LSOF_FILE, "lsof evidence", false); + if (lsof === null) throw new Error("lsof evidence is unreadable"); + } else { + try { + lsof = cp.execFileSync("lsof", ["-nP", `-iTCP:${process.env.LAVISH_PORT}`, "-sTCP:ESTABLISHED"], {encoding:"utf8"}); + } catch (error) { + if (error.stdout === undefined) throw error; + lsof = String(error.stdout); + } + } browserConnections = lsof.split("\n").filter(line => /^(Google|Chromium|Chrome)\s/.test(line)).length; -} catch { unreadableHome = true; } +} 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 (row?.file && ps.split("\n").some(line => line.includes("lavish-axi poll") && line.includes(row.file))) attached.set(row.key, "live-poll-process"); + 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 { unreadableHome = true; } +} catch (error) { + addInventoryError("runtime", `cannot inspect live poll processes: ${error.message}`); +} -const isUnder = (file, root) => file === root || file.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`); -const lastActivityFor = (row, ledger) => { - const values = [row.updated_at, ledger?.last_polled_at].filter(Boolean).map(value => Date.parse(value)).filter(Number.isFinite); +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 dataOwnerFor = file => { - for (const home of homes) { - const dataRoot = path.join(home,"data"); - if (!isUnder(file,dataRoot)) continue; - const task = path.relative(dataRoot,file).split(path.sep)[0]; - if (task) return {home,task}; - } - return null; +const homeBoardOwnersFor = file => normalizedHomes.filter(home => file === path.join(home, ".lavish", "bearings-board.html")).map(home => ({home,task:"home"})); +const validSessionRow = row => { + 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 (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); }; const evidenceFor = row => { const evidence = []; - let classification = "ambiguous"; - if (!row || !row.key || !row.file) return {classification,evidence:["malformed-registry-row"]}; + if (!validSessionRow(row)) return {classification:"ambiguous",evidence:["malformed-registry-row"]}; if (row.status === "ended") return {classification:"preserve", evidence:["historical-ended-registry-row"]}; - const exists = fs.existsSync(row.file); - if (!exists) return {classification:"ambiguous", evidence:["unsupported-by-current-Lavish","artifact-missing"]}; + 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 currentOwners = currentOwnersFor(row.file); + const rowLedgers = ledgerRowsFor(row.key); + const dataOwners = dataOwnersFor(row.file); + const boardOwners = 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) { + 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 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 (sources.has(sid)) evidence.push("registered-process-event-source"); if (decisions.has(sid)) evidence.push("open-decision-binding"); - if (attached.has(row.key)) evidence.push(`attached-${attached.get(row.key)}`); - const current = meta.find(owner => owner.worktree && isUnder(row.file, owner.worktree)); - if (current) evidence.push(`current-task:${current.task}`); - const ledger = ledgers.get(row.key); - if (ledger) { - const ledgerHome = ledger.home; - const live = meta.some(owner => owner.home === ledgerHome && owner.task === ledger.task_id); - if (live) evidence.push(`ledger-live-task:${ledger.task_id}`); - } - const dataOwner = dataOwnerFor(row.file); - const heldKey = ledger ? `${ledger.home}\0${ledger.task_id}` : dataOwner ? `${dataOwner.home}\0${dataOwner.task}` : ""; - if (heldKey && held.has(heldKey)) evidence.push(`retained-backlog-hold:${held.get(heldKey)}`); + 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"); - if (evidence.length) return {classification:"preserve",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("home-durable-review:")) || currentOwners.length || rowLedgers.some(owner => currentOwners.some(item => item.home === owner.home && item.task === owner.task_id)) || 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"]}; - const lastActivity = lastActivityFor(row, ledger); + 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 && process.env.IGNORE_UNMAPPED_BROWSER !== "1") return {classification:"ambiguous",evidence:[`idle-expired:${expiryHours}h`,`unmapped-browser-connections:${browserConnections}`]}; - if (expired) return {classification:"eligible",evidence:[`idle-expired:${expiryHours}h`,"existing-artifact","no-review-owner-or-client"]}; + if (expired && browserConnections > 0) return {classification:"ambiguous",evidence:[`idle-expired:${expiryHours}h`,`unmapped-browser-connections:${browserConnections}`]}; - let closedOwner = null; - if (ledger && closed.has(`${ledger.home}\0${ledger.task_id}`)) closedOwner = `${ledger.home}:${ledger.task_id}`; - if (!closedOwner && dataOwner && closed.has(`${dataOwner.home}\0${dataOwner.task}`)) closedOwner = `${dataOwner.home}:${dataOwner.task}`; - if (closedOwner && unreadableHome) return {classification:"ambiguous",evidence:[`closed-task:${closedOwner}`,"ownership-incomplete-unreadable-home"]}; - if (closedOwner && row.status === "open" && browserConnections > 0 && process.env.IGNORE_UNMAPPED_BROWSER !== "1") return {classification:"ambiguous",evidence:[`closed-task:${closedOwner}`,`unmapped-browser-connections:${browserConnections}`]}; - if (closedOwner && row.status === "open") return {classification:"eligible",evidence:[`closed-task:${closedOwner}`,"existing-artifact","no-review-owner-or-client"]}; - if (unreadableHome) return {classification:"ambiguous",evidence:["ownership-incomplete-unreadable-home"]}; + 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 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 (["open","feedback","ended"].includes(row.status)) counts[row.status]++; - if (row.status !== "ended" && !fs.existsSync(row.file || "")) counts.missing_file++; - const ledger = ledgers.get(row.key); - const lastActivity = lastActivityFor(row, ledger); - if (row.status === "open" && Number.isFinite(lastActivity) && Date.now() - lastActivity >= expiryHours * 60 * 60 * 1000) counts.past_expiry++; - const live = meta.some(owner => owner.worktree && row.file && isUnder(row.file, owner.worktree)); - if (row.status !== "ended") counts[live ? "with_live_task" : "without_live_task"]++; + 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 (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 === "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); - 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); + 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='' ignore_browser=0 + 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 ;; - --ignore-unmapped-browser) ignore_browser=1; shift ;; *) usage ;; esac done @@ -259,7 +483,7 @@ cmd_audit() { trap "rm -f -- '$homes'" EXIT make_homes_file "$homes" FM_LAVISH_IDLE_EXPIRY_HOURS="$expiry" FM_LAVISH_PRESERVE_PATHS_FILE="$preserve_paths" \ - FM_LAVISH_IGNORE_UNMAPPED_BROWSER="$ignore_browser" run_audit_node audit "$homes" "$freeze" + run_audit_node audit "$homes" "$freeze" } cmd_summary() { @@ -280,50 +504,141 @@ count_registry() { 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"}], + ["6aba2ed4c6df33d3", {url:"http://127.0.0.1:4387/session/6aba2ed4c6df33d3",file:"/Users/ivan/Projects/firstmate/data/nancy-direction-board-d1/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 (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 current verdict homes audit_output expiry=48 preserve_paths='' ignore_browser=0 + 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 ;; - --ignore-unmapped-browser) ignore_browser=1; shift ;; *) 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" - # shellcheck disable=SC2064 # Expand the function-local path while it is in scope. - trap "rm -f -- '$homes'" EXIT + 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 - IFS=$'\t' read -r key file expected_status expected_updated </dev/null 2>&1 || die "lavish-axi is not installed" - lavish-axi end "$file" >/dev/null || die "lavish-axi could not end candidate $key" + 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 < "$candidate" + done < "$queue" if [ $((processed % batch)) -ne 0 ] || [ "$processed" -eq 0 ]; then printf 'batch-complete: processed=%s %s\n' "$processed" "$(count_registry)"; fi } diff --git a/bin/fm-lavish-session.sh b/bin/fm-lavish-session.sh index 0d36ab75efd..b6f04fb4afd 100755 --- a/bin/fm-lavish-session.sh +++ b/bin/fm-lavish-session.sh @@ -11,7 +11,10 @@ # fm-lavish-session.sh register-auto [] # fm-lavish-session.sh safe-park # fm-lavish-session.sh end +# 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; @@ -26,6 +29,9 @@ 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" + die() { printf 'error: %s\n' "$1" >&2; exit 1; } usage() { sed -n '2,/^set -u$/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; exit 2; } @@ -39,6 +45,9 @@ canonical_file() { } ledger_path() { printf '%s/%s.lavish-sessions\n' "$STATE" "$1"; } +ledger_lock_path() { printf '%s/.%s.lavish-sessions.lock\n' "$STATE" "$1"; } +lavish_state_dir() { printf '%s\n' "${LAVISH_STATE_FILE%/*}"; } +lavish_cli() { LAVISH_AXI_STATE_DIR="$(lavish_state_dir)" command lavish-axi "$@"; } session_json_for_file() { ARTIFACT_REAL="$1" LAVISH_STATE_FILE="$LAVISH_STATE_FILE" node <<'NODE' @@ -56,14 +65,17 @@ NODE } write_registration() { - local task=$1 real=$2 disposition=$3 session_json=$4 ledger tmp + 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" + fm_lock_acquire_wait "$lock" || die "cannot lock the Lavish ledger for $task" tmp=$(umask 077; mktemp "$STATE/.${task}.lavish-sessions.XXXXXX") \ - || die "cannot stage the Lavish ledger" - TASK_ID="$task" HOME_REAL="$(canonical_file "$FM_HOME")" ARTIFACT_REAL="$real" \ + || { 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"; die "cannot update the Lavish ledger"; } + || { 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)) { @@ -74,7 +86,13 @@ const rows = []; if (fs.existsSync(process.env.LEDGER)) { for (const line of fs.readFileSync(process.env.LEDGER, "utf8").split("\n")) { if (!line) continue; - try { rows.push(JSON.parse(line)); } catch { console.error("existing Lavish ledger is malformed"); process.exit(1); } + 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); @@ -92,8 +110,9 @@ 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"; die "cannot protect the Lavish ledger"; } - mv -f "$tmp" "$ledger" || { rm -f "$tmp"; die "cannot publish the Lavish ledger"; } + 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() { @@ -109,28 +128,64 @@ cmd_register() { } resolve_owner() { - local artifact=$1 explicit=${2-} real meta task worktree matches=0 owner='' disposition='' + 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" - owner=$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 - [ -f "$meta" ] && [ ! -L "$meta" ] || continue + [ -e "$meta" ] || [ -L "$meta" ] || continue + [ -f "$meta" ] && [ ! -L "$meta" ] || die "task metadata is not a safe regular file: $meta" task=${meta##*/}; task=${task%.meta} - [ -z "$owner" ] || [ "$task" = "$owner" ] || continue - worktree=$(sed -n 's/^worktree=//p' "$meta" | tail -1) + 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 - owner=$task; disposition=ephemeral-worktree; matches=$((matches + 1)) + owners+=("$task"); dispositions+=(ephemeral-worktree) fi done - if [ -n "$owner" ] && { [ "$real" = "$FM_HOME/data/$owner" ] || [[ "$real" == "$FM_HOME/data/$owner"/* ]]; }; then - disposition='durable-review' - matches=$((matches + 1)) + + 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 - [ -n "$owner" ] || die "cannot establish a task owner for Lavish artifact: $real" - [ -n "$disposition" ] || die "artifact is outside task $owner's worktree and durable data directory: $real" - [ "$matches" -eq 1 ] || die "Lavish artifact ownership is ambiguous for task $owner: $real" + 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" } @@ -144,14 +199,17 @@ cmd_register_auto() { } mark_ended() { - local task=$1 key=$2 ledger tmp + local task=$1 key=$2 ledger tmp lock ledger=$(ledger_path "$task") + lock=$(ledger_lock_path "$task") + [ -f "$ledger" ] && [ ! -L "$ledger" ] || die "cannot find the Lavish ledger for $task" + fm_lock_acquire_wait "$lock" || die "cannot lock the Lavish ledger for $task" tmp=$(umask 077; mktemp "$STATE/.${task}.lavish-sessions.XXXXXX") \ - || die "cannot stage the Lavish ledger" + || { fm_lock_release "$lock"; die "cannot stage the Lavish ledger"; } KEY="$key" LEDGER="$ledger" node <<'NODE' > "$tmp" \ - || { rm -f "$tmp"; die "cannot record the ended Lavish session"; } + || { rm -f "$tmp"; fm_lock_release "$lock"; die "cannot record the ended Lavish session"; } const fs = require("node:fs"); -const rows = fs.readFileSync(process.env.LEDGER, "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); +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; } @@ -161,15 +219,17 @@ if (!found) process.exit(1); NODE if ! chmod 0600 "$tmp" || ! mv -f "$tmp" "$ledger"; then rm -f "$tmp" + fm_lock_release "$lock" die "cannot publish the ended Lavish ledger" fi + fm_lock_release "$lock" || die "cannot release the Lavish ledger lock" } 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-axi end "$real" >/dev/null || die "lavish-axi could not end $real" + 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")); @@ -182,7 +242,7 @@ NODE printf 'ended: %s %s\n' "$key" "$real" } -ledger_rows() { +ledger_rows_unlocked() { local task=$1 disposition=${2-} ledger ledger=$(ledger_path "$task") [ -f "$ledger" ] && [ ! -L "$ledger" ] || return 0 @@ -194,14 +254,72 @@ for (const line of fs.readFileSync(process.env.LEDGER, "utf8").split("\n")) { 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") + [ -f "$ledger" ] && [ ! -L "$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") + [ -f "$ledger" ] && [ ! -L "$ledger" ] || return 0 + 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" NOW="$(date -u '+%Y-%m-%dT%H:%M:%S.%3NZ')" 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)); +for (const row of rows) if (!row.ended_at && row.artifact === process.env.ARTIFACT_REAL) row.last_polled_at = process.env.NOW; +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_poll_activity() { + local artifact=${1-} task=${2-} real ledger name + [ "$#" -ge 1 ] && [ "$#" -le 2 ] || usage + [ -f "$artifact" ] && [ ! -L "$artifact" ] || return 0 + real=$(canonical_file "$artifact") || return 0 + if [ -n "$task" ]; then + validate_task_id "$task" + touch_ledger_poll "$task" "$real" || die "cannot refresh the Lavish poll activity ledger" + return 0 + fi + for ledger in "$STATE"/*.lavish-sessions; do + [ -f "$ledger" ] && [ ! -L "$ledger" ] || continue + name=${ledger##*/}; name=${name%.lavish-sessions} + validate_task_id "$name" + touch_ledger_poll "$name" "$real" || die "cannot refresh the Lavish poll activity ledger" + done } guard_durable_end() { - local task=$1 real=$2 key=$3 source_id result hold_status=0 session_json + local task=$1 real=$2 key=$3 allow_source=${4-} source_id result hold_status=0 session_json source_id=$("$SCRIPT_DIR/fm-procevent-lavish.sh" source-id "$real") || return 1 - [ ! -e "$STATE/procevent/$source_id.source" ] \ + [ ! -e "$STATE/procevent/$source_id.source" ] || [ "$source_id" = "$allow_source" ] \ || die "durable Lavish review still has a registered process-event source: $source_id" [ ! -e "$STATE/decision-bindings/$source_id.origin" ] \ || die "durable Lavish review still has an open decision binding: $source_id" @@ -227,13 +345,33 @@ NODE fi } +find_active_row() { + local task=$1 real=$2 row + row=$(ledger_rows "$task" | 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" + fi +} + cmd_end() { local task=${1-} artifact=${2-} real row key disposition [ "$#" -eq 2 ] || usage validate_task_id "$task" real=$(canonical_file "$artifact") - row=$(ledger_rows "$task" | awk -F '\t' -v file="$real" '$1 == file { print; exit }') - [ -n "$row" ] || die "artifact is not an active recorded session for task $task: $real" + row=$(find_active_row "$task" "$real") key=${row#*$'\t'}; key=${key%%$'\t'*} disposition=${row##*$'\t'} if [ "$disposition" = durable-review ]; then @@ -246,24 +384,76 @@ cmd_end_ephemeral() { local task=${1-} rows real key disposition [ "$#" -eq 1 ] || usage validate_task_id "$task" - rows=$(ledger_rows "$task" ephemeral-worktree) || die "cannot read the Lavish ledger for $task" - 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 + [ -f "$ledger" ] && [ ! -L "$ledger" ] || continue + 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() { @@ -282,7 +472,7 @@ cmd_safe_park() { fi durable_real=$(canonical_file "$durable") command -v lavish-axi >/dev/null 2>&1 || die "lavish-axi is not installed" - lavish-axi "$durable_real" >/dev/null || die "cannot serve the durable Lavish artifact" + 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" @@ -314,8 +504,11 @@ 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-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 ;; diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index fe08edc1a25..44ef66b38d8 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -134,6 +134,7 @@ 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}" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" @@ -145,6 +146,9 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" die() { printf 'error: %s\n' "$1" >&2; exit 1; } usage() { sed -n '2,/^set -u$/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; exit 2; } +lavish_state_dir() { printf '%s\n' "${LAVISH_STATE_FILE%/*}"; } +lavish_cli() { LAVISH_AXI_STATE_DIR="$(lavish_state_dir)" command lavish-axi "$@"; } + # 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. @@ -179,8 +183,13 @@ cmd_arm() { # 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 + if [ -n "$task" ]; then + "$SCRIPT_DIR/fm-procevent.sh" register lavish "$id" \ + -- "$SCRIPT_DIR/fm-procevent-lavish.sh" poll "$real" --task-id "$task" || exit 1 + else + "$SCRIPT_DIR/fm-procevent.sh" register lavish "$id" \ + -- "$SCRIPT_DIR/fm-procevent-lavish.sh" poll "$real" || exit 1 + fi if [ "${FM_LAVISH_LEDGER_TEST_BYPASS:-0}" != 1 ]; then if [ -n "$task" ]; then "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$real" "$task" >/dev/null @@ -205,6 +214,7 @@ cmd_retire() { cmd_retire_and_end() { local task=${1-} artifact=${2-} [ "$#" -eq 2 ] || usage + "$SCRIPT_DIR/fm-lavish-session.sh" preflight-end "$task" "$artifact" || exit 1 cmd_retire "$artifact" || exit 1 "$SCRIPT_DIR/fm-lavish-session.sh" end "$task" "$artifact" } @@ -286,10 +296,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" @@ -306,7 +320,14 @@ cmd_poll() { trap "$cleanup_command; trap - $signal; kill -$signal $$" "$signal" done while :; do - lavish-axi poll "$artifact" | poll_response_filter "$response" + if [ "${FM_LAVISH_LEDGER_TEST_BYPASS:-0}" != 1 ]; then + 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 + fi + lavish_cli poll "$artifact" | poll_response_filter "$response" pipeline_status=("${PIPESTATUS[@]}") rc=${pipeline_status[0]} filter_rc=${pipeline_status[1]} @@ -356,7 +377,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 diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index e2a08921b18..028220e4894 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2489,6 +2489,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 +2581,34 @@ 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 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 + 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 diff --git a/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json b/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json new file mode 100644 index 00000000000..185bb221c38 --- /dev/null +++ b/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json @@ -0,0 +1,27 @@ +{ + "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": [], + "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": "6aba2ed4c6df33d3", + "url": "http://127.0.0.1:4387/session/6aba2ed4c6df33d3", + "file": "/Users/ivan/Projects/firstmate/data/nancy-direction-board-d1/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" + } + ] +} diff --git a/data/fm-lavish-session-prune-f1/report.md b/data/fm-lavish-session-prune-f1/report.md new file mode 100644 index 00000000000..b20de3f453c --- /dev/null +++ b/data/fm-lavish-session-prune-f1/report.md @@ -0,0 +1,87 @@ +# Lavish session prune f1 + +## Scope and custody + +This report records the accepted 2026-09-08 lifecycle ruling and the evidence retained by the implementation. +The implementation is local to Firstmate and does not open, modify, or push `kunchenguid/lavish-axi`. +Missing artifact paths remain unsupported by Lavish 0.1.63 and are never mutated. + +## Final numbers + +The final read-only investigation snapshot contained 416 registry rows: 371 open, 18 feedback, and 27 ended. +The crash-diagnosis snapshot contained 415 rows: 370 open, 18 feedback, and 27 ended. +Of those 370 open rows, 8 mapped to current ordinary task metadata and 362 were lifecycle-closed or unowned from Firstmate's perspective. +The 370 open rows included 267 existing artifact paths and 103 missing artifact paths. +Firstmate held 22 registered Lavish process-event sources, 9 live poll processes, and 2 Chrome SSE streams at inspection time. +The bootstrap diagnostic distinguishes registry rows from live connections, stays silent below 20 open rows, and warns from 50 open rows without pruning. + +## Ambiguous evidence retained + +The three kept boards are excluded by exact key, URL, and artifact path in `authorized-2026-09-08.json`. +The fleet bearings board is a home-owned durable review with owner `home` and path `$FM_HOME/.lavish/bearings-board.html`. +Unreadable or malformed primary or registered secondmate inventory refuses classification rather than producing an eligible row. +Multiple matching task metadata rows, multiple live ledger rows, unlisted ledger homes, browser connections, registered sources, bindings, feedback, prompts, holds, and unacknowledged delivery remain preservation or ambiguity evidence. +Ledger rows are finalized only after the Lavish state transition is verified, and ledger read/replace operations are locked. + +## Apply contract + +Plain `apply` accepts only a frozen eligible candidate. +`apply --authorized []` additionally accepts only rows explicitly listed as ambiguous in a validated authority file with the exact 2026-09-08 ruling and three exclusions. +Every row is rechecked against its frozen key, file, URL, status, and timestamp before ending. +Application stops at the first contradiction, operates in batches of at most 50, recounts after each batch, and never deletes records, state, chat, attachments, or artifact files. + +## 6. SAFEGUARD design — upstream issue draft + +### Title + +`Bound live-session listeners and release SSE/watchers on end` + +### Body + +Lavish 0.1.63 uses one process-global EventEmitter and installs callbacks per live connection. +Each `/api/poll` request adds `feedback` and `ended` listeners. +Each `/events/:key` SSE connection adds `reload`, `agent-reply`, `agent-presence`, `layout-warnings`, and `ended` listeners. +The handlers filter by key after every global emit, so event cost is linear in all live connections and Node warns when the eleventh connection/listener arrives. + +Disconnect cleanup is present and works, so the warning alone should not be called a historical-session leak. +The end path is incomplete, though. +`POST /api/end` marks the session ended and emits `ended`, but it neither removes/closes the session's chokidar watcher nor terminates matching SSE responses. +The browser receives `ended` and disables its UI, but its `EventSource` remains open. +Those callbacks and the watcher survive until the tab disconnects or the whole server shuts down. + +#### Reproduction + +1. Start an isolated Lavish 0.1.63 server with isolated state. +2. Create and open eleven small HTML artifacts. +3. Hold one agent poll open for each artifact. +4. Observe `MaxListenersExceededWarning` for `feedback` and `ended` when listener eleven is registered. +5. Attach eleven SSE clients to the corresponding `/events/:key` routes. +6. Observe warnings for `reload`, `agent-reply`, `agent-presence`, `layout-warnings`, and `ended`. +7. End one session with `lavish-axi end ` while leaving its SSE client connected. +8. Observe the final `ended` event, then observe that the stream remains connected and a watcher for that key remains in the server map. +9. Emit an event for one key and observe every listener execute its key filter although only one client consumes the event. + +#### Expected + +Listener count should be bounded independently of the number of live review sessions. +Ending a session should send one final ended event, close/remove its SSE subscribers, and close/remove its file watcher. +Browser clients should close or unsubscribe from their EventSource when they enter ended state. + +#### Suggested implementation + +Replace per-connection global EventEmitter subscriptions with keyed subscriber maps, such as `Map>` and `Map>`, and dispatch directly to the changed key. +Alternatively keep one shared listener per event and route through keyed maps, but do not add one emitter listener per response. +On end, deliver the final event, terminate and remove the matching SSE responses, close and delete the matching watcher, and clear any keyed waiters after their terminal response. +Have the browser call `EventSource.close()` on end; if the local SharedWorker solution is adopted, unsubscribe the key and close the origin stream when its subscriber set reaches zero. +Add tests asserting bounded emitter/listener counts with at least 50 live sessions and asserting watcher/SSE cleanup after end. +Do not solve this by raising or disabling `setMaxListeners`, because that preserves global O(N) fan-out and hides missing end cleanup. + +### Existing related work + +The closed upstream issue https://github.com/kunchenguid/lavish-axi/issues/171 added the ended event and read-only browser UI, but its fix stops short of closing the stream or watcher. +An all-issue keyword scan found no existing listener-bounding, bulk-end, archive, or prune issue; open issue https://github.com/kunchenguid/lavish-axi/issues/308 concerns a read-only session list. + +The local-only commit `c9f08d3cb10c68435e10d000673bc167db849bb3` already prototypes browser connection sharing with a SharedWorker. +Its retained E2E report at `data/lavish-chrome-connlimit-c1/findings.md:28-48` shows eleven tabs loading with only two Chrome sockets and working live updates. +That commit is based on older local main, is not installed, and does not by itself fix agent-poll fan-out or watcher/SSE cleanup on end. +It is useful salvage material, not current proof that upstream is fixed. diff --git a/docs/configuration.md b/docs/configuration.md index 1e3c2eab810..fc929be6534 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -720,6 +720,8 @@ Lavish review sessions have a default 48-hour idle expiry in `bin/fm-lavish-audi 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. 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 < Date: Tue, 8 Sep 2026 11:59:32 +0800 Subject: [PATCH 05/12] fix: complete lavish lifecycle safeguards --- .gitignore | 2 + bin/fm-lavish-audit.sh | 2 +- bin/fm-lavish-session.sh | 4 +- .../authorized-2026-09-08.json | 1641 ++++++++++++++++- data/fm-lavish-session-prune-f1/report.md | 149 +- .../upstream-issue-draft.md | 53 + docs/documentation-audiences.json | 8 + tests/fm-lavish-session.test.sh | 58 +- 8 files changed, 1821 insertions(+), 96 deletions(-) create mode 100644 data/fm-lavish-session-prune-f1/upstream-issue-draft.md diff --git a/.gitignore b/.gitignore index 93a64d2207b..f0e8ea776ca 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ projects/ state/ data/* !data/fm-lavish-session-prune-f1/ +data/fm-lavish-session-prune-f1/* !data/fm-lavish-session-prune-f1/report.md !data/fm-lavish-session-prune-f1/authorized-2026-09-08.json +!data/fm-lavish-session-prune-f1/upstream-issue-draft.md scratchpad* .no-mistakes/ .lavish/ diff --git a/bin/fm-lavish-audit.sh b/bin/fm-lavish-audit.sh index 1a40eff1dd8..5ddbb367c0a 100755 --- a/bin/fm-lavish-audit.sh +++ b/bin/fm-lavish-audit.sh @@ -533,7 +533,7 @@ if (process.env.AUTHORITY_FILE) { 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"}], - ["6aba2ed4c6df33d3", {url:"http://127.0.0.1:4387/session/6aba2ed4c6df33d3",file:"/Users/ivan/Projects/firstmate/data/nancy-direction-board-d1/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(); diff --git a/bin/fm-lavish-session.sh b/bin/fm-lavish-session.sh index b6f04fb4afd..9be6f016557 100755 --- a/bin/fm-lavish-session.sh +++ b/bin/fm-lavish-session.sh @@ -283,10 +283,10 @@ touch_ledger_poll() { fm_lock_release "$lock" return 1 } - ARTIFACT_REAL="$real" NOW="$(date -u '+%Y-%m-%dT%H:%M:%S.%3NZ')" LEDGER="$ledger" node <<'NODE' > "$tmp" + 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)); -for (const row of rows) if (!row.ended_at && row.artifact === process.env.ARTIFACT_REAL) row.last_polled_at = process.env.NOW; +for (const row of rows) if (!row.ended_at && row.artifact === process.env.ARTIFACT_REAL) row.last_polled_at = new Date().toISOString(); for (const row of rows) process.stdout.write(`${JSON.stringify(row)}\n`); NODE rc=$? diff --git a/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json b/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json index 185bb221c38..bf22b00e661 100644 --- a/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json +++ b/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json @@ -3,7 +3,1640 @@ "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": [], + "authorized": [ + { + "key": "00348516af86abfa", + "file": "/Users/ivan/Projects/firstmate/data/carenbloom-loewe-page-w8/index.html", + "url": "http://127.0.0.1:4387/session/00348516af86abfa", + "status": "open", + "updated_at": "2026-07-30T22:52:23.950Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "00f4d431d1c83ce1", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-4-script-fm.html", + "url": "http://127.0.0.1:4387/session/00f4d431d1c83ce1", + "status": "open", + "updated_at": "2026-08-14T10:13:30.303Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "01e03d8567965e87", + "file": "/Users/ivan/Projects/firstmate/data/recess-upright-char-r8/gallery.html", + "url": "http://127.0.0.1:4387/session/01e03d8567965e87", + "status": "open", + "updated_at": "2026-07-29T09:32:52.757Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "063fea78b0ab2d22", + "file": "/Users/ivan/Projects/firstmate/data/idel-cute-motion-economy-opus-u6/review.html", + "url": "http://127.0.0.1:4387/session/063fea78b0ab2d22", + "status": "open", + "updated_at": "2026-08-01T15:04:40.001Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "06a184da28538f77", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-1-script.html", + "url": "http://127.0.0.1:4387/session/06a184da28538f77", + "status": "open", + "updated_at": "2026-08-16T07:02:08.907Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "084e8ab0e4205b60", + "file": "/Users/ivan/Projects/firstmate/data/memory-review-2026-08-21/.lavish/memory-review.html", + "url": "http://127.0.0.1:4387/session/084e8ab0e4205b60", + "status": "open", + "updated_at": "2026-08-21T08:55:58.091Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "0dfd0a2261dc23a7", + "file": "/Users/ivan/Projects/firstmate/data/pilo-dirtytalk-game-campaign-opus-m8/slice.html", + "url": "http://127.0.0.1:4387/session/0dfd0a2261dc23a7", + "status": "open", + "updated_at": "2026-08-03T00:17:53.270Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "0e87a0302e8ff079", + "file": "/Users/ivan/Projects/firstmate/data/idel-key-screen-storyboard-b7/review.html", + "url": "http://127.0.0.1:4387/session/0e87a0302e8ff079", + "status": "open", + "updated_at": "2026-08-03T00:13:33.595Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "0f1cd9e645439376", + "file": "/Users/ivan/Projects/firstmate/data/pilo-duo-screens-v3/prototype/l1-game-v3.html", + "url": "http://127.0.0.1:4387/session/0f1cd9e645439376", + "status": "open", + "updated_at": "2026-07-30T14:57:49.105Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "1221f67357c63017", + "file": "/Users/ivan/Projects/firstmate/data/idel-yard-mission-p0-k6/review.html", + "url": "http://127.0.0.1:4387/session/1221f67357c63017", + "status": "open", + "updated_at": "2026-08-02T03:07:07.003Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "12ff2d553931ee07", + "file": "/Users/ivan/Projects/firstmate/data/recess-hype-articles-w9/review.html", + "url": "http://127.0.0.1:4387/session/12ff2d553931ee07", + "status": "open", + "updated_at": "2026-08-03T00:13:35.429Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "133ab26ac419a4d4", + "file": "/Users/ivan/Projects/firstmate/data/pilo-day2-floorseq-family-a1/out/board.html", + "url": "http://127.0.0.1:4387/session/133ab26ac419a4d4", + "status": "open", + "updated_at": "2026-08-26T04:48:50.703Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "13ed28a6b0916ba9", + "file": "/Users/ivan/Projects/firstmate/data/recess-cover-lander-k4/index.html", + "url": "http://127.0.0.1:4387/session/13ed28a6b0916ba9", + "status": "open", + "updated_at": "2026-07-30T13:25:51.537Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "14fcd73e42191483", + "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/dino.html", + "url": "http://127.0.0.1:4387/session/14fcd73e42191483", + "status": "open", + "updated_at": "2026-08-19T09:43:19.185Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "156dadaa56f17869", + "file": "/Users/ivan/Projects/firstmate/data/pilo-day2-curtains-family-a1/out/board.html", + "url": "http://127.0.0.1:4387/session/156dadaa56f17869", + "status": "open", + "updated_at": "2026-08-26T05:06:16.788Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "15bc062b38cdf572", + "file": "/Users/ivan/Projects/firstmate/data/idel-creature-hunt-fable-f5/.lavish/idel-creature-board.html", + "url": "http://127.0.0.1:4387/session/15bc062b38cdf572", + "status": "open", + "updated_at": "2026-08-20T06:01:05.953Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "16434f3d37386bb9", + "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/study.html", + "url": "http://127.0.0.1:4387/session/16434f3d37386bb9", + "status": "open", + "updated_at": "2026-08-19T10:21:05.196Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "1655e94e35a77127", + "file": "/Users/ivan/Projects/firstmate/data/recess-cover-round3-k9/gallery.html", + "url": "http://127.0.0.1:4387/session/1655e94e35a77127", + "status": "open", + "updated_at": "2026-07-29T04:06:04.286Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "18fdf1281bc94117", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-1-script-reformat.html", + "url": "http://127.0.0.1:4387/session/18fdf1281bc94117", + "status": "open", + "updated_at": "2026-08-17T06:05:25.529Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "190a8637a01b505a", + "file": "/Users/ivan/Projects/firstmate/data/idel-creature-hunt-grok-g1/.lavish/board.html", + "url": "http://127.0.0.1:4387/session/190a8637a01b505a", + "status": "open", + "updated_at": "2026-08-20T05:34:01.712Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "19dbec5d7ca1a169", + "file": "/Users/ivan/Projects/firstmate/data/carenbloom-opus-v6-o5/review.html", + "url": "http://127.0.0.1:4387/session/19dbec5d7ca1a169", + "status": "open", + "updated_at": "2026-07-31T09:27:05.487Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "223367148752378e", + "file": "/Users/ivan/Projects/firstmate/data/recess-cover-round4-p2/gallery.html", + "url": "http://127.0.0.1:4387/session/223367148752378e", + "status": "open", + "updated_at": "2026-07-29T05:31:15.462Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "22a71b146b2151a6", + "file": "/Users/ivan/Projects/firstmate/data/recess-consumer-research-v8/review.html", + "url": "http://127.0.0.1:4387/session/22a71b146b2151a6", + "status": "open", + "updated_at": "2026-08-01T15:13:33.896Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "230ffddf07168c2d", + "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/lighter.html", + "url": "http://127.0.0.1:4387/session/230ffddf07168c2d", + "status": "open", + "updated_at": "2026-08-20T01:19:03.011Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "23c0043ecbb278e9", + "file": "/Users/ivan/Projects/pvs-ideation-r3-fable/.lavish/hearth/index.html", + "url": "http://127.0.0.1:4387/session/23c0043ecbb278e9", + "status": "open", + "updated_at": "2026-08-11T01:55:49.959Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "23d3a30c7340702c", + "file": "/Users/ivan/Projects/firstmate/.lavish/review-first-2026-08-16.html", + "url": "http://127.0.0.1:4387/session/23d3a30c7340702c", + "status": "open", + "updated_at": "2026-08-16T03:18:49.465Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "26e3a752f33d6000", + "file": "/Users/ivan/Projects/firstmate/data/recess-viz-nightstand-anchor-v4/index.html", + "url": "http://127.0.0.1:4387/session/26e3a752f33d6000", + "status": "open", + "updated_at": "2026-08-09T07:39:34.636Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "27e18771d215da87", + "file": "/Users/ivan/Projects/firstmate/data/carenbloom-furnace-v3-l7/index.html", + "url": "http://127.0.0.1:4387/session/27e18771d215da87", + "status": "open", + "updated_at": "2026-07-30T23:51:10.669Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "2d13fba848ae2651", + "file": "/Users/ivan/Projects/firstmate/data/recess-tc-key-images-k7/articles-review.html", + "url": "http://127.0.0.1:4387/session/2d13fba848ae2651", + "status": "open", + "updated_at": "2026-07-31T05:17:06.735Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "2d1d9615178e1acb", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-full-read.html", + "url": "http://127.0.0.1:4387/session/2d1d9615178e1acb", + "status": "open", + "updated_at": "2026-08-17T09:07:23.157Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "2d41f8af740465e6", + "file": "/Users/ivan/Projects/firstmate/data/recess-beautiful-object-y5/gallery.html", + "url": "http://127.0.0.1:4387/session/2d41f8af740465e6", + "status": "open", + "updated_at": "2026-07-29T14:11:43.559Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "2f67255d4bd1bff2", + "file": "/Users/ivan/Projects/firstmate/data/pilo-dirtytalk-reorder-blocker-r5/review.html", + "url": "http://127.0.0.1:4387/session/2f67255d4bd1bff2", + "status": "open", + "updated_at": "2026-07-31T08:43:23.642Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "2fe3448f4d09d868", + "file": "/Users/ivan/Projects/firstmate/data/pilo-quiz-opening-creative-f1/prototype/index.html", + "url": "http://127.0.0.1:4387/session/2fe3448f4d09d868", + "status": "open", + "updated_at": "2026-08-14T11:30:30.331Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "32cef3e82b816286", + "file": "/Users/ivan/Projects/firstmate/data/pilo-eve-walk-fix-x1/out/review/full-dr-dl/eve-walk-set-review.html", + "url": "http://127.0.0.1:4387/session/32cef3e82b816286", + "status": "open", + "updated_at": "2026-08-26T05:40:53.265Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "33e1888c82408fdb", + "file": "/Users/ivan/Projects/firstmate/data/secondmate-model-eval-bv-e1/.lavish/model-scorecard.html", + "url": "http://127.0.0.1:4387/session/33e1888c82408fdb", + "status": "open", + "updated_at": "2026-08-31T08:30:13.075Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "3ac79d1211ec55fa", + "file": "/Users/ivan/Projects/firstmate/data/recess-calcube-r9/gallery.html", + "url": "http://127.0.0.1:4387/session/3ac79d1211ec55fa", + "status": "open", + "updated_at": "2026-07-29T09:58:48.090Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "3b0fdd794d1497fe", + "file": "/Users/ivan/Projects/firstmate/data/blockvalley-viewport-mock/mock.html", + "url": "http://127.0.0.1:4387/session/3b0fdd794d1497fe", + "status": "open", + "updated_at": "2026-08-28T13:00:49.169Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "3b841d7af79c0d2d", + "file": "/Users/ivan/Projects/firstmate/data/recess-viz-table-season-v1/page/index.html", + "url": "http://127.0.0.1:4387/session/3b841d7af79c0d2d", + "status": "open", + "updated_at": "2026-08-09T07:45:30.870Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "3d0376b403e59507", + "file": "/Users/ivan/Projects/firstmate/data/carenbloom-b-content-audit-r5/review.html", + "url": "http://127.0.0.1:4387/session/3d0376b403e59507", + "status": "open", + "updated_at": "2026-07-30T01:03:23.338Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "3dab4dd7f6a6cfa9", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-day-3-blind-v1.html", + "url": "http://127.0.0.1:4387/session/3dab4dd7f6a6cfa9", + "status": "open", + "updated_at": "2026-08-17T02:41:34.452Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "4062caa64f5a4b1d", + "file": "/Users/ivan/Projects/firstmate/data/recess-viz-hearth-shelf-v3/page/hearth-shelf.html", + "url": "http://127.0.0.1:4387/session/4062caa64f5a4b1d", + "status": "open", + "updated_at": "2026-08-09T07:45:17.704Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "47bd0fd338dd7159", + "file": "/Users/ivan/Projects/firstmate/data/pilo-l1-day1-assets-e1/figma-export/bed-1-set-pilot-review.html", + "url": "http://127.0.0.1:4387/session/47bd0fd338dd7159", + "status": "open", + "updated_at": "2026-08-25T03:11:49.630Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "491ba7c3fa2a5640", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-5-script-k3.html", + "url": "http://127.0.0.1:4387/session/491ba7c3fa2a5640", + "status": "open", + "updated_at": "2026-08-15T00:11:20.890Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "4d308ec1d4c3c744", + "file": "/Users/ivan/Projects/firstmate/data/kin-look-screens-f5/.lavish/board.html", + "url": "http://127.0.0.1:4387/session/4d308ec1d4c3c744", + "status": "open", + "updated_at": "2026-08-19T02:19:12.699Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "4d45fffb588fdb64", + "file": "/Users/ivan/Projects/firstmate/data/recess-nightsolo-proto-r7/gallery.html", + "url": "http://127.0.0.1:4387/session/4d45fffb588fdb64", + "status": "open", + "updated_at": "2026-07-29T09:10:41.926Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "4f72b1c1a809b6b3", + "file": "/Users/ivan/Projects/firstmate/data/omawild-home-chain-f1/index.html", + "url": "http://127.0.0.1:4387/session/4f72b1c1a809b6b3", + "status": "open", + "updated_at": "2026-09-06T01:51:38.045Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "50e08b1eab033a8d", + "file": "/Users/ivan/Projects/firstmate/data/recess-streak-object-d4/gallery.html", + "url": "http://127.0.0.1:4387/session/50e08b1eab033a8d", + "status": "open", + "updated_at": "2026-08-03T00:13:30.149Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "516e72ed74190eb2", + "file": "/Users/ivan/Projects/firstmate/data/pilo-eve-sweep-cast-x1/out/review/eve-hand-sweep-motion-review.html", + "url": "http://127.0.0.1:4387/session/516e72ed74190eb2", + "status": "open", + "updated_at": "2026-08-26T04:50:47.478Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "52030c8f8b333e7b", + "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/spine.html", + "url": "http://127.0.0.1:4387/session/52030c8f8b333e7b", + "status": "open", + "updated_at": "2026-08-20T00:48:10.124Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "52f131b4c177a5ed", + "file": "/Users/ivan/Projects/firstmate/data/blockvalley-ux-v1/board.html", + "url": "http://127.0.0.1:4387/session/52f131b4c177a5ed", + "status": "open", + "updated_at": "2026-08-28T13:19:57.607Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "5697268fb6701a5c", + "file": "/Users/ivan/Projects/firstmate/data/recess-t1-deepen-renders-m6/gallery.html", + "url": "http://127.0.0.1:4387/session/5697268fb6701a5c", + "status": "open", + "updated_at": "2026-07-29T03:13:37.690Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "5984d98e57bec724", + "file": "/Users/ivan/Projects/firstmate/data/pilo-dirtytalk-science-audit-s7/review.html", + "url": "http://127.0.0.1:4387/session/5984d98e57bec724", + "status": "open", + "updated_at": "2026-07-31T07:41:56.272Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "5a2e0444b89a32f5", + "file": "/Users/ivan/Projects/firstmate/data/pilo-day1-assets-board/index.html", + "url": "http://127.0.0.1:4387/session/5a2e0444b89a32f5", + "status": "open", + "updated_at": "2026-08-28T01:24:38.819Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "5a7905a99092492d", + "file": "/Users/ivan/Projects/firstmate/data/recess-cover-round2-b7/gallery.html", + "url": "http://127.0.0.1:4387/session/5a7905a99092492d", + "status": "open", + "updated_at": "2026-07-29T03:43:41.236Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "5e23d01a556cc897", + "file": "/Users/ivan/Projects/firstmate/data/carenbloom-pb-chip-icons-opus-i1/contact-sheet.html", + "url": "http://127.0.0.1:4387/session/5e23d01a556cc897", + "status": "open", + "updated_at": "2026-08-05T00:37:04.043Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "5e8ea550cdf8ce0c", + "file": "/Users/ivan/Projects/firstmate/.lavish/bearings-board.html", + "url": "http://127.0.0.1:4387/session/5e8ea550cdf8ce0c", + "status": "open", + "updated_at": "2026-08-24T01:25:07.120Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "629e597525c878e8", + "file": "/Users/ivan/Projects/firstmate/data/herdr-hermes-phone-setup-j5/review.html", + "url": "http://127.0.0.1:4387/session/629e597525c878e8", + "status": "open", + "updated_at": "2026-08-01T01:10:46.850Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "63358c163c2d9d58", + "file": "/Users/ivan/Projects/firstmate/data/carenbloom-bloom-anchor-h4/gallery.html", + "url": "http://127.0.0.1:4387/session/63358c163c2d9d58", + "status": "open", + "updated_at": "2026-07-30T10:03:28.039Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "676a7ac1da0ebe3d", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-day-4-script.html", + "url": "http://127.0.0.1:4387/session/676a7ac1da0ebe3d", + "status": "open", + "updated_at": "2026-08-17T07:02:03.818Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "67e2d8af12deded0", + "file": "/Users/ivan/Projects/firstmate/data/pilo-eve-walk-fix-x1/out/review/dr-cleanup-only/eve-dr-cleanup-review.html", + "url": "http://127.0.0.1:4387/session/67e2d8af12deded0", + "status": "open", + "updated_at": "2026-08-26T02:56:09.152Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "69603190944cb435", + "file": "/Users/ivan/Projects/firstmate/data/idel-onboarding-screens-opus-t5/review.html", + "url": "http://127.0.0.1:4387/session/69603190944cb435", + "status": "open", + "updated_at": "2026-08-01T13:49:23.644Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "6997e44e73e94489", + "file": "/Users/ivan/Projects/firstmate/data/recess-leader-product-f5/.lavish/board.html", + "url": "http://127.0.0.1:4387/session/6997e44e73e94489", + "status": "open", + "updated_at": "2026-08-24T02:08:30.585Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "6b08d02dd8b4d0b5", + "file": "/Users/ivan/Projects/firstmate/data/pilo-duolingo-animation-opus-v7/review.html", + "url": "http://127.0.0.1:4387/session/6b08d02dd8b4d0b5", + "status": "open", + "updated_at": "2026-08-02T03:27:15.579Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "6b51f4be99ab9c8d", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-story.html", + "url": "http://127.0.0.1:4387/session/6b51f4be99ab9c8d", + "status": "open", + "updated_at": "2026-08-17T06:35:24.082Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "6fd453221275826d", + "file": "/Users/ivan/Projects/firstmate/data/omawild-home-expedition-f2/index.html", + "url": "http://127.0.0.1:4387/session/6fd453221275826d", + "status": "open", + "updated_at": "2026-09-06T03:00:26.353Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "71dc17f0cad104e8", + "file": "/Users/ivan/Projects/firstmate/data/idel-look-ab-board-o5/.lavish/board.html", + "url": "http://127.0.0.1:4387/session/71dc17f0cad104e8", + "status": "open", + "updated_at": "2026-08-22T06:07:48.139Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "7576729b50ec3670", + "file": "/Users/ivan/Projects/firstmate/data/pilo-dirtytalk-script-v2-o5/review.html", + "url": "http://127.0.0.1:4387/session/7576729b50ec3670", + "status": "open", + "updated_at": "2026-08-03T00:13:33.882Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "75dcf745aadddda2", + "file": "/Users/ivan/Projects/firstmate/data/pilo-l1-gap-proof-r1/.lavish/index.html", + "url": "http://127.0.0.1:4387/session/75dcf745aadddda2", + "status": "open", + "updated_at": "2026-08-25T09:24:29.404Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "77934c2dd48ec086", + "file": "/Users/ivan/Projects/firstmate/data/toy-points-review/points.html", + "url": "http://127.0.0.1:4387/session/77934c2dd48ec086", + "status": "open", + "updated_at": "2026-08-11T07:55:10.110Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "78640ebb2576c28b", + "file": "/Users/ivan/Projects/pvs-ideation-r3-fable/.lavish/brief/index.html", + "url": "http://127.0.0.1:4387/session/78640ebb2576c28b", + "status": "open", + "updated_at": "2026-08-11T03:25:16.867Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "78a5312713282776", + "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/worlds.html", + "url": "http://127.0.0.1:4387/session/78a5312713282776", + "status": "open", + "updated_at": "2026-08-19T10:51:15.603Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "7999b176973e5803", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-day-2-blind-v2.html", + "url": "http://127.0.0.1:4387/session/7999b176973e5803", + "status": "open", + "updated_at": "2026-08-17T02:53:11.202Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "7a543cce5f00a279", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-review.html", + "url": "http://127.0.0.1:4387/session/7a543cce5f00a279", + "status": "open", + "updated_at": "2026-08-16T06:47:10.690Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "7e083bade2bd7340", + "file": "/Users/ivan/Projects/firstmate/data/recess-calcube-r10-x4/gallery.html", + "url": "http://127.0.0.1:4387/session/7e083bade2bd7340", + "status": "open", + "updated_at": "2026-07-29T10:42:28.783Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "8111cd15f145583c", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-4-script-k3.html", + "url": "http://127.0.0.1:4387/session/8111cd15f145583c", + "status": "open", + "updated_at": "2026-08-15T00:11:11.958Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "85ca93b6d32d62b8", + "file": "/Users/ivan/Projects/firstmate/data/omawild-madeinmay-v2-m8/index.html", + "url": "http://127.0.0.1:4387/session/85ca93b6d32d62b8", + "status": "open", + "updated_at": "2026-07-31T00:07:00.317Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "85cdb2e77ba73904", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-days.html", + "url": "http://127.0.0.1:4387/session/85cdb2e77ba73904", + "status": "open", + "updated_at": "2026-08-13T07:15:14.423Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "8692fd4d2425fa04", + "file": "/Users/ivan/Projects/firstmate/data/pilo-map-regeneration-review/.lavish/location-1-map-assets.html", + "url": "http://127.0.0.1:4387/session/8692fd4d2425fa04", + "status": "open", + "updated_at": "2026-08-20T01:18:41.365Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "8d1ff022150f01a3", + "file": "/Users/ivan/Projects/firstmate/data/recess-strategic-review-x9/review.html", + "url": "http://127.0.0.1:4387/session/8d1ff022150f01a3", + "status": "open", + "updated_at": "2026-07-30T01:22:06.802Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "9148f6b99feb9def", + "file": "/Users/ivan/Projects/firstmate/data/idel-market-ux-audit-x7/.lavish/idel-market-readiness/index.html", + "url": "http://127.0.0.1:4387/session/9148f6b99feb9def", + "status": "open", + "updated_at": "2026-08-03T00:13:33.593Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "926e41fe9035b3ab", + "file": "/Users/ivan/Projects/firstmate/data/kin-look-sky-k3/.lavish/board.html", + "url": "http://127.0.0.1:4387/session/926e41fe9035b3ab", + "status": "open", + "updated_at": "2026-08-19T04:48:59.937Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "9315e87b6ede7f43", + "file": "/Users/ivan/Projects/firstmate/data/idel-funnel-screens-c1/.lavish/look.html", + "url": "http://127.0.0.1:4387/session/9315e87b6ede7f43", + "status": "open", + "updated_at": "2026-08-19T00:09:47.678Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "9348a7e9d53a422c", + "file": "/Users/ivan/Projects/firstmate/data/recess-alarm-design-v6/gallery.html", + "url": "http://127.0.0.1:4387/session/9348a7e9d53a422c", + "status": "open", + "updated_at": "2026-07-30T07:10:32.409Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "95d84bc5853e4d8b", + "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/refs.html", + "url": "http://127.0.0.1:4387/session/95d84bc5853e4d8b", + "status": "open", + "updated_at": "2026-08-19T13:16:01.250Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "98684925b1dbfa45", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-3-script-k3.html", + "url": "http://127.0.0.1:4387/session/98684925b1dbfa45", + "status": "open", + "updated_at": "2026-08-14T09:56:54.448Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "98af896703b64cd3", + "file": "/Users/ivan/Projects/firstmate/data/pilo-consumer-games-prototype-fable-m12/review.html", + "url": "http://127.0.0.1:4387/session/98af896703b64cd3", + "status": "open", + "updated_at": "2026-08-03T00:13:30.317Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "9c95dfa64a0d2a69", + "file": "/Users/ivan/Projects/firstmate/data/recess-viz-combined-surface-c1/page/index.html", + "url": "http://127.0.0.1:4387/session/9c95dfa64a0d2a69", + "status": "open", + "updated_at": "2026-08-09T08:01:22.993Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "9f7ce51140dd6c47", + "file": "/Users/ivan/Projects/firstmate/data/pilo-quiz-combat-reports/.lavish/quiz-combat-board.html", + "url": "http://127.0.0.1:4387/session/9f7ce51140dd6c47", + "status": "open", + "updated_at": "2026-08-20T14:30:44.143Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "a67f03b24cc611d5", + "file": "/Users/ivan/Projects/firstmate/data/carenbloom-furnace-v2-h6/index.html", + "url": "http://127.0.0.1:4387/session/a67f03b24cc611d5", + "status": "open", + "updated_at": "2026-07-31T00:27:15.895Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "a6f12e7004f554ff", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-day-3-script.html", + "url": "http://127.0.0.1:4387/session/a6f12e7004f554ff", + "status": "open", + "updated_at": "2026-08-17T06:35:24.287Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "a757f9e7e96e2251", + "file": "/Users/ivan/Projects/firstmate/data/pilo-arc-synthesis-f3/review/index.html", + "url": "http://127.0.0.1:4387/session/a757f9e7e96e2251", + "status": "open", + "updated_at": "2026-08-05T09:27:31.682Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "aa486da0a4e28314", + "file": "/Users/ivan/Projects/firstmate/data/recess-round6-resize-g4/gallery.html", + "url": "http://127.0.0.1:4387/session/aa486da0a4e28314", + "status": "open", + "updated_at": "2026-08-03T00:13:30.234Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "ab40edc06f73fe03", + "file": "/Users/ivan/Projects/firstmate/data/recess-round5b-pin1fix-s7/gallery.html", + "url": "http://127.0.0.1:4387/session/ab40edc06f73fe03", + "status": "open", + "updated_at": "2026-07-29T07:50:09.518Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "abae2c960c0a91fd", + "file": "/Users/ivan/Projects/pvs-ideation-r3-fable/.lavish/report.html", + "url": "http://127.0.0.1:4387/session/abae2c960c0a91fd", + "status": "open", + "updated_at": "2026-08-10T12:57:09.227Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "abd0d67cbcfbc017", + "file": "/Users/ivan/Projects/firstmate/data/carenbloom-furnace-v4-r4/index.html", + "url": "http://127.0.0.1:4387/session/abd0d67cbcfbc017", + "status": "open", + "updated_at": "2026-07-31T05:03:40.004Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "ad4434b12072616e", + "file": "/Users/ivan/Projects/firstmate/data/idel-look-screens-k3/.lavish/look.html", + "url": "http://127.0.0.1:4387/session/ad4434b12072616e", + "status": "open", + "updated_at": "2026-08-18T13:39:27.921Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "b4106cba9b7dae2f", + "file": "/Users/ivan/Projects/firstmate/data/pilo-l1-day1-assets-e1/day1-status-board.html", + "url": "http://127.0.0.1:4387/session/b4106cba9b7dae2f", + "status": "open", + "updated_at": "2026-08-25T01:51:44.983Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "b820228d99fcb230", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/script-governing.html", + "url": "http://127.0.0.1:4387/session/b820228d99fcb230", + "status": "open", + "updated_at": "2026-08-16T06:39:49.123Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "b8d98e551b679502", + "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/creature.html", + "url": "http://127.0.0.1:4387/session/b8d98e551b679502", + "status": "open", + "updated_at": "2026-08-19T14:26:25.419Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "bea3aa9908f5818d", + "file": "/Users/ivan/Projects/firstmate/data/recess-onepager-e5/onepager.html", + "url": "http://127.0.0.1:4387/session/bea3aa9908f5818d", + "status": "open", + "updated_at": "2026-07-29T07:08:01.488Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "c0c3a17a7acd0a07", + "file": "/Users/ivan/Projects/firstmate/data/pilo-chaise-authored-a1/out/board.html", + "url": "http://127.0.0.1:4387/session/c0c3a17a7acd0a07", + "status": "open", + "updated_at": "2026-08-26T04:53:16.368Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "c2ee188626dc7df0", + "file": "/Users/ivan/Projects/firstmate/data/idel-motion-creative-opus-d9/motion-board.html", + "url": "http://127.0.0.1:4387/session/c2ee188626dc7df0", + "status": "open", + "updated_at": "2026-08-03T00:13:29.790Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "cabe385f6c5007a6", + "file": "/Users/ivan/Projects/firstmate/data/recess-99-day-render-p6/gallery.html", + "url": "http://127.0.0.1:4387/session/cabe385f6c5007a6", + "status": "open", + "updated_at": "2026-07-29T12:55:31.991Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "cb59980d2458124e", + "file": "/Users/ivan/Projects/firstmate/data/pilo-dirtytalk-game-campaign-opus-m8/review.html", + "url": "http://127.0.0.1:4387/session/cb59980d2458124e", + "status": "open", + "updated_at": "2026-08-03T00:13:33.780Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "cbe8d2151d1f5925", + "file": "/Users/ivan/Projects/firstmate/data/kin-look-screens-g1/.lavish/board.html", + "url": "http://127.0.0.1:4387/session/cbe8d2151d1f5925", + "status": "open", + "updated_at": "2026-08-19T05:20:39.495Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "cccb262ea6e10e0f", + "file": "/Users/ivan/Projects/firstmate/data/recess-pair-concept-h2/gallery.html", + "url": "http://127.0.0.1:4387/session/cccb262ea6e10e0f", + "status": "open", + "updated_at": "2026-07-29T13:21:01.820Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "cfcfd3fd47364241", + "file": "/Users/ivan/Projects/firstmate/data/recess-round5-refblend-h3/gallery.html", + "url": "http://127.0.0.1:4387/session/cfcfd3fd47364241", + "status": "open", + "updated_at": "2026-07-29T07:35:00.429Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "d2d54cf75b5708a6", + "file": "/Users/ivan/Projects/pvs/strategy/ideation-2026-08-k3/review.html", + "url": "http://127.0.0.1:4387/session/d2d54cf75b5708a6", + "status": "open", + "updated_at": "2026-08-10T09:25:12.122Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "d2d5fcf2f8edde38", + "file": "/Users/ivan/Projects/firstmate/data/recess-five-products/articles.html", + "url": "http://127.0.0.1:4387/session/d2d5fcf2f8edde38", + "status": "open", + "updated_at": "2026-07-31T01:09:15.623Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "d3a5ed74193c6c6e", + "file": "/Users/ivan/Projects/firstmate/data/recess-digital-detox-universe-d7/review.html", + "url": "http://127.0.0.1:4387/session/d3a5ed74193c6c6e", + "status": "open", + "updated_at": "2026-08-07T05:08:23.262Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "d3cc76ed993f5a66", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-family-review.html", + "url": "http://127.0.0.1:4387/session/d3cc76ed993f5a66", + "status": "open", + "updated_at": "2026-08-17T01:21:48.357Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "d6b62e2e545e77e1", + "file": "/Users/ivan/Projects/firstmate/data/pilo-game-structure-v1/.lavish/structure.html", + "url": "http://127.0.0.1:4387/session/d6b62e2e545e77e1", + "status": "open", + "updated_at": "2026-08-18T03:42:33.460Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "d74d2c25200ed42a", + "file": "/Users/ivan/Projects/firstmate/data/pilo-l1-day1-assets-e1/figma-export/proofs/day1-amendment8-final-board.html", + "url": "http://127.0.0.1:4387/session/d74d2c25200ed42a", + "status": "open", + "updated_at": "2026-08-25T05:57:39.036Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "d7785081978d67d2", + "file": "/Users/ivan/Projects/firstmate/data/recess-design-log-b6/logsheet.html", + "url": "http://127.0.0.1:4387/session/d7785081978d67d2", + "status": "open", + "updated_at": "2026-08-07T05:08:37.790Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "db82656675684ecb", + "file": "/Users/ivan/Projects/firstmate/data/pilo-rig-fidelity-t8/rig.html", + "url": "http://127.0.0.1:4387/session/db82656675684ecb", + "status": "open", + "updated_at": "2026-07-29T13:39:37.148Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "dcb201dc4d909dba", + "file": "/Users/ivan/Projects/firstmate/data/pilo-quiz-learnfirst-c16/.lavish/quiz-program.html", + "url": "http://127.0.0.1:4387/session/dcb201dc4d909dba", + "status": "open", + "updated_at": "2026-08-18T01:11:00.182Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "de66983011fc7ef3", + "file": "/Users/ivan/Projects/firstmate/data/recess-agency-connection-time-map-m7/review.html", + "url": "http://127.0.0.1:4387/session/de66983011fc7ef3", + "status": "open", + "updated_at": "2026-07-31T09:38:01.273Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "e5354c20992ce973", + "file": "/Users/ivan/Projects/firstmate/data/carenbloom-flower-bloom-b5/continuity-review.html", + "url": "http://127.0.0.1:4387/session/e5354c20992ce973", + "status": "open", + "updated_at": "2026-07-31T01:08:22.401Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "e562d48dbb42d80b", + "file": "/Users/ivan/Projects/firstmate/data/kin-days15-consumer-f5b/.lavish/kin-days15-consumer.html", + "url": "http://127.0.0.1:4387/session/e562d48dbb42d80b", + "status": "open", + "updated_at": "2026-08-22T05:58:58.417Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "e77092a97419998c", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-days-v2.html", + "url": "http://127.0.0.1:4387/session/e77092a97419998c", + "status": "open", + "updated_at": "2026-08-16T06:39:49.196Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "e7893ed598e4fc9e", + "file": "/Users/ivan/Projects/firstmate/data/kin-product-plan-k3/.lavish/plan.html", + "url": "http://127.0.0.1:4387/session/e7893ed598e4fc9e", + "status": "open", + "updated_at": "2026-08-19T00:39:43.614Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "ea04dcd2c5b49655", + "file": "/Users/ivan/Projects/firstmate/data/pilo-eve-r4-walk/.lavish/index.html", + "url": "http://127.0.0.1:4387/session/ea04dcd2c5b49655", + "status": "open", + "updated_at": "2026-08-25T09:56:17.087Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "ea8ead9a790b6cee", + "file": "/Users/ivan/Projects/firstmate/data/limmi-batteryless-feasibility-f8/review.html", + "url": "http://127.0.0.1:4387/session/ea8ead9a790b6cee", + "status": "open", + "updated_at": "2026-08-03T00:13:33.876Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "eb25c65d307ebe32", + "file": "/Users/ivan/Projects/firstmate/data/recess-viz-offline-league-v6/page/index.html", + "url": "http://127.0.0.1:4387/session/eb25c65d307ebe32", + "status": "open", + "updated_at": "2026-08-09T07:42:29.149Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "ebea37ae0b1fa773", + "file": "/Users/ivan/Projects/firstmate/data/idel-look-screens-f5/.lavish/look.html", + "url": "http://127.0.0.1:4387/session/ebea37ae0b1fa773", + "status": "open", + "updated_at": "2026-08-18T13:39:17.604Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "ecd8682e4bbb1da3", + "file": "/Users/ivan/Projects/firstmate/data/carenbloom-flower-bloom-b5/video-review.html", + "url": "http://127.0.0.1:4387/session/ecd8682e4bbb1da3", + "status": "open", + "updated_at": "2026-07-31T01:32:13.830Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "ef013954cbd21969", + "file": "/Users/ivan/Projects/firstmate/data/idel-funnel-screens-g1/.lavish/look.html", + "url": "http://127.0.0.1:4387/session/ef013954cbd21969", + "status": "open", + "updated_at": "2026-08-19T00:09:41.574Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "f0b8f0fd98c21197", + "file": "/Users/ivan/Projects/firstmate/data/idel-funnel-screens-k3/.lavish/look.html", + "url": "http://127.0.0.1:4387/session/f0b8f0fd98c21197", + "status": "open", + "updated_at": "2026-08-19T00:09:45.526Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "f20d0d91892fbb74", + "file": "/Users/ivan/Projects/firstmate/data/pilo-day2-chair-proving-r1/board.html", + "url": "http://127.0.0.1:4387/session/f20d0d91892fbb74", + "status": "open", + "updated_at": "2026-08-26T04:52:27.991Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "f2a44fb777baa8ec", + "file": "/Users/ivan/Projects/firstmate/data/carenbloom-furnace-page-f7/index.html", + "url": "http://127.0.0.1:4387/session/f2a44fb777baa8ec", + "status": "open", + "updated_at": "2026-07-30T23:34:09.903Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "f562dfadb2999ea8", + "file": "/Users/ivan/Projects/firstmate/data/recess-viz-stakes-ledger-v5/page/index.html", + "url": "http://127.0.0.1:4387/session/f562dfadb2999ea8", + "status": "open", + "updated_at": "2026-08-09T07:44:29.878Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "f78567ba0297cc26", + "file": "/Users/ivan/Projects/firstmate/data/recess-hype-editor-e6/review.html", + "url": "http://127.0.0.1:4387/session/f78567ba0297cc26", + "status": "open", + "updated_at": "2026-08-03T00:13:35.252Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "fa7053fd0444afcd", + "file": "/Users/ivan/Projects/firstmate/data/pilo-quiz-opening-proto-x1/prototype/index.html", + "url": "http://127.0.0.1:4387/session/fa7053fd0444afcd", + "status": "open", + "updated_at": "2026-08-14T10:02:06.383Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "fb67369082483021", + "file": "/Users/ivan/Projects/firstmate/data/idel-sprout-concept-c1/.lavish/review.html", + "url": "http://127.0.0.1:4387/session/fb67369082483021", + "status": "open", + "updated_at": "2026-08-20T12:01:07.121Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "fc783b7879f4d793", + "file": "/Users/ivan/Projects/firstmate/.lavish/three-parked-decisions-2026-07-15.html", + "url": "http://127.0.0.1:4387/session/fc783b7879f4d793", + "status": "open", + "updated_at": "2026-08-03T00:13:30.157Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + }, + { + "key": "fdbf52f8fa5fc5a2", + "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-day-5-script.html", + "url": "http://127.0.0.1:4387/session/fdbf52f8fa5fc5a2", + "status": "open", + "updated_at": "2026-08-17T07:02:04.312Z", + "evidence": [ + "captain-authorized-ambiguous", + "no-positive-closed-task-owner" + ], + "classification": "ambiguous" + } + ], "excluded": [ { "key": "7f59a8c16dff9f19", @@ -12,9 +1645,9 @@ "reason": "kept board named in the 2026-09-08 ruling" }, { - "key": "6aba2ed4c6df33d3", - "url": "http://127.0.0.1:4387/session/6aba2ed4c6df33d3", - "file": "/Users/ivan/Projects/firstmate/data/nancy-direction-board-d1/board/index.html", + "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" }, { diff --git a/data/fm-lavish-session-prune-f1/report.md b/data/fm-lavish-session-prune-f1/report.md index b20de3f453c..9b7a97e494a 100644 --- a/data/fm-lavish-session-prune-f1/report.md +++ b/data/fm-lavish-session-prune-f1/report.md @@ -1,87 +1,62 @@ -# Lavish session prune f1 - -## Scope and custody - -This report records the accepted 2026-09-08 lifecycle ruling and the evidence retained by the implementation. -The implementation is local to Firstmate and does not open, modify, or push `kunchenguid/lavish-axi`. -Missing artifact paths remain unsupported by Lavish 0.1.63 and are never mutated. - -## Final numbers - -The final read-only investigation snapshot contained 416 registry rows: 371 open, 18 feedback, and 27 ended. -The crash-diagnosis snapshot contained 415 rows: 370 open, 18 feedback, and 27 ended. -Of those 370 open rows, 8 mapped to current ordinary task metadata and 362 were lifecycle-closed or unowned from Firstmate's perspective. -The 370 open rows included 267 existing artifact paths and 103 missing artifact paths. -Firstmate held 22 registered Lavish process-event sources, 9 live poll processes, and 2 Chrome SSE streams at inspection time. -The bootstrap diagnostic distinguishes registry rows from live connections, stays silent below 20 open rows, and warns from 50 open rows without pruning. - -## Ambiguous evidence retained - -The three kept boards are excluded by exact key, URL, and artifact path in `authorized-2026-09-08.json`. -The fleet bearings board is a home-owned durable review with owner `home` and path `$FM_HOME/.lavish/bearings-board.html`. -Unreadable or malformed primary or registered secondmate inventory refuses classification rather than producing an eligible row. -Multiple matching task metadata rows, multiple live ledger rows, unlisted ledger homes, browser connections, registered sources, bindings, feedback, prompts, holds, and unacknowledged delivery remain preservation or ambiguity evidence. -Ledger rows are finalized only after the Lavish state transition is verified, and ledger read/replace operations are locked. - -## Apply contract - -Plain `apply` accepts only a frozen eligible candidate. -`apply --authorized []` additionally accepts only rows explicitly listed as ambiguous in a validated authority file with the exact 2026-09-08 ruling and three exclusions. -Every row is rechecked against its frozen key, file, URL, status, and timestamp before ending. -Application stops at the first contradiction, operates in batches of at most 50, recounts after each batch, and never deletes records, state, chat, attachments, or artifact files. - -## 6. SAFEGUARD design — upstream issue draft - -### Title - -`Bound live-session listeners and release SSE/watchers on end` - -### Body - -Lavish 0.1.63 uses one process-global EventEmitter and installs callbacks per live connection. -Each `/api/poll` request adds `feedback` and `ended` listeners. -Each `/events/:key` SSE connection adds `reload`, `agent-reply`, `agent-presence`, `layout-warnings`, and `ended` listeners. -The handlers filter by key after every global emit, so event cost is linear in all live connections and Node warns when the eleventh connection/listener arrives. - -Disconnect cleanup is present and works, so the warning alone should not be called a historical-session leak. -The end path is incomplete, though. -`POST /api/end` marks the session ended and emits `ended`, but it neither removes/closes the session's chokidar watcher nor terminates matching SSE responses. -The browser receives `ended` and disables its UI, but its `EventSource` remains open. -Those callbacks and the watcher survive until the tab disconnects or the whole server shuts down. - -#### Reproduction - -1. Start an isolated Lavish 0.1.63 server with isolated state. -2. Create and open eleven small HTML artifacts. -3. Hold one agent poll open for each artifact. -4. Observe `MaxListenersExceededWarning` for `feedback` and `ended` when listener eleven is registered. -5. Attach eleven SSE clients to the corresponding `/events/:key` routes. -6. Observe warnings for `reload`, `agent-reply`, `agent-presence`, `layout-warnings`, and `ended`. -7. End one session with `lavish-axi end ` while leaving its SSE client connected. -8. Observe the final `ended` event, then observe that the stream remains connected and a watcher for that key remains in the server map. -9. Emit an event for one key and observe every listener execute its key filter although only one client consumes the event. - -#### Expected - -Listener count should be bounded independently of the number of live review sessions. -Ending a session should send one final ended event, close/remove its SSE subscribers, and close/remove its file watcher. -Browser clients should close or unsubscribe from their EventSource when they enter ended state. - -#### Suggested implementation - -Replace per-connection global EventEmitter subscriptions with keyed subscriber maps, such as `Map>` and `Map>`, and dispatch directly to the changed key. -Alternatively keep one shared listener per event and route through keyed maps, but do not add one emitter listener per response. -On end, deliver the final event, terminate and remove the matching SSE responses, close and delete the matching watcher, and clear any keyed waiters after their terminal response. -Have the browser call `EventSource.close()` on end; if the local SharedWorker solution is adopted, unsubscribe the key and close the origin stream when its subscriber set reaches zero. -Add tests asserting bounded emitter/listener counts with at least 50 live sessions and asserting watcher/SSE cleanup after end. -Do not solve this by raising or disabling `setMaxListeners`, because that preserves global O(N) fan-out and hides missing end cleanup. - -### Existing related work - -The closed upstream issue https://github.com/kunchenguid/lavish-axi/issues/171 added the ended event and read-only browser UI, but its fix stops short of closing the stream or watcher. -An all-issue keyword scan found no existing listener-bounding, bulk-end, archive, or prune issue; open issue https://github.com/kunchenguid/lavish-axi/issues/308 concerns a read-only session list. - -The local-only commit `c9f08d3cb10c68435e10d000673bc167db849bb3` already prototypes browser connection sharing with a SharedWorker. -Its retained E2E report at `data/lavish-chrome-connlimit-c1/findings.md:28-48` shows eleven tabs loading with only two Chrome sockets and working live updates. -That commit is based on older local main, is not installed, and does not by itself fix agent-poll fan-out or watcher/SSE cleanup on end. -It is useful salvage material, not current proof that upstream is fixed. +# Lavish session lifecycle and conservative prune report + +## Outcome + +Firstmate now owns Lavish sessions through an explicit per-task or home ledger, teardown-time ephemeral ending, verified safe-park transfer, a narrow lifecycle-owner `retire-and-end`, a bootstrap registry diagnostic, and a dry-run-first audit/apply helper. +The captain-authorized migration verified 76 ambiguous session transitions to ended before stopping on the first contradictory result, as required. +One already-ended protected review was re-served, so the net registry change was 75 fewer open rows: 372 before and 297 after. +The target of at most 20 genuinely active open sessions was not reached because Lavish reported success without ending one session, the stop-on-contradiction contract left 60 frozen candidates unattempted, and 111 missing-path rows remain unsupported by Lavish 0.1.63. +Every ended board remains on disk and can be re-served because this migration deleted no artifact files. + +## Live migration + +The migration snapshot contained 417 total rows: 372 open, 18 feedback, and 27 ended. +An early classifier pass incorrectly treated retained Nancy direction key `6aba2ed4c6df33d3` as eligible because it recognized closed backlog rows but not their retained `hold-kind` field. +The supported `lavish-axi end ` path temporarily ended that row and verified its transition. +The retained backlog record was then found, the session was restored through supported no-open serve semantics, and the classifier was corrected and regression-tested. +The final classifier preserves that row with `retained-backlog-hold:parked` evidence. + +The captain subsequently authorized ending all 136 existing-path ambiguous rows in the frozen set except three review artifacts mentioned that day. +Those kept artifacts were Nancy tennis directions key `7f59a8c16dff9f19`, Ally paid media key `4ae99e8ad06d4a8c`, and the 食·養·打 review under `data/syd-board-b1/board/` key `cc73671c247bff78`. +The apply helper ended and verified 76 rows in batches of ten. +After 70 successful transitions, the eighth batch stopped at key `85cdb2e77ba73904` for `/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-days.html` because `lavish-axi end` exited successfully but the registry row remained open. +The contradictory row was not retried, and the remaining 60 frozen candidates were not attempted. +The protected Nancy tennis directions and Ally paid-media sessions remained open. +The protected 食·養·打 review had already been ended historically, so it was re-served through supported no-open semantics and remains open. + +The final snapshot contains 417 total rows: 297 open, 18 feedback, and 102 ended. +Of the open rows, 253 were past the new 48-hour idle expiry. +The snapshot had 23 active poll registrations, ten mapped live poll clients, and three unmapped Chrome connections. +The shared server was not stopped, restarted, signalled, or reconfigured. +No Lavish record, Firstmate state record, chat, attachment, or artifact file was deleted. + +## Remaining ambiguous evidence + +The final read-only audit classified 245 rows as preserve and 172 as ambiguous. +The ambiguous set contained 61 existing artifacts with `idle-expired:48h,unmapped-browser-connections:3` evidence and 111 missing artifacts with `unsupported-by-current-Lavish,artifact-missing` evidence. +The 61 existing rows include the contradictory key plus the 60 frozen candidates left unattempted after the stop. +The 111 missing-path rows were skipped because Lavish 0.1.63 requires `realpath` of an existing file for its supported one-session end command. +No replacement file was synthesized and `state.json` was not edited. + +The final preserve set contained 102 historical ended rows plus current task ownership, retained worktrees, captain holds, decision bindings, registered or live process-event sources, feedback or prompts, unacknowledged delivery, mapped clients, unresolved layout-warning repair, and the three explicit kept boards. +Unreadable or malformed home inventory now refuses eligibility, multiple candidate owners remain ambiguous, and an unkeyed browser connection cannot be bypassed by ordinary apply. + +## Implementation and verification + +`bin/fm-lavish-session.sh` owns the locked ledger, supported end path, home-owned durable bearings board, real poll activity time, and verified ledger finalization. +`bin/fm-lavish-audit.sh` owns conservative classification, frozen candidates, the explicit 2026-09-08 authorization record, bounded apply, and read-only coverage of the primary and registered secondmate homes. +`bin/fm-procevent-lavish.sh arm` registers ownership and refreshes activity on every poll iteration. +Plain `retire` remains narrow, while `retire-and-end` preflights the durable-end guard before removing any source or binding. +`bin/fm-teardown.sh` ends and verifies task and secondmate-child ephemeral sessions before process reaping or worktree return. +`bin/fm-bootstrap.sh` stays silent below 20 open registry rows and emits one actionable warning from 50 upward while distinguishing historical rows from live connections. +Bootstrap reports the past-expiry count but never ends a session automatically. + +Behavior tests execute the public scripts against isolated Firstmate homes and Lavish state directories. +They cover ledger registration and locking, owner ambiguity, home-owned bearings, poll activity, verified end and finalization, safe-park ordering, durable-end preflight, conservative inventory failure, authorized frozen apply, secondmate-child teardown, bootstrap thresholds, and summary counts. + +## Upstream custody + +The verbatim section 6 note is in `data/fm-lavish-session-prune-f1/upstream-issue-draft.md`. +No issue or pull request was opened against `kunchenguid/lavish-axi`. +Nothing was pushed to that repository. +The draft remains parked for a later captain decision. diff --git a/data/fm-lavish-session-prune-f1/upstream-issue-draft.md b/data/fm-lavish-session-prune-f1/upstream-issue-draft.md new file mode 100644 index 00000000000..ab9e2521844 --- /dev/null +++ b/data/fm-lavish-session-prune-f1/upstream-issue-draft.md @@ -0,0 +1,53 @@ +### Title + +`Bound live-session listeners and release SSE/watchers on end` + +### Body + +Lavish 0.1.63 uses one process-global EventEmitter and installs callbacks per live connection. +Each `/api/poll` request adds `feedback` and `ended` listeners. +Each `/events/:key` SSE connection adds `reload`, `agent-reply`, `agent-presence`, `layout-warnings`, and `ended` listeners. +The handlers filter by key after every global emit, so event cost is linear in all live connections and Node warns when the eleventh connection/listener arrives. + +Disconnect cleanup is present and works, so the warning alone should not be called a historical-session leak. +The end path is incomplete, though. +`POST /api/end` marks the session ended and emits `ended`, but it neither removes/closes the session's chokidar watcher nor terminates matching SSE responses. +The browser receives `ended` and disables its UI, but its `EventSource` remains open. +Those callbacks and the watcher survive until the tab disconnects or the whole server shuts down. + +#### Reproduction + +1. Start an isolated Lavish 0.1.63 server with isolated state. +2. Create and open eleven small HTML artifacts. +3. Hold one agent poll open for each artifact. +4. Observe `MaxListenersExceededWarning` for `feedback` and `ended` when listener eleven is registered. +5. Attach eleven SSE clients to the corresponding `/events/:key` routes. +6. Observe warnings for `reload`, `agent-reply`, `agent-presence`, `layout-warnings`, and `ended`. +7. End one session with `lavish-axi end ` while leaving its SSE client connected. +8. Observe the final `ended` event, then observe that the stream remains connected and a watcher for that key remains in the server map. +9. Emit an event for one key and observe every listener execute its key filter although only one client consumes the event. + +#### Expected + +Listener count should be bounded independently of the number of live review sessions. +Ending a session should send one final ended event, close/remove its SSE subscribers, and close/remove its file watcher. +Browser clients should close or unsubscribe from their EventSource when they enter ended state. + +#### Suggested implementation + +Replace per-connection global EventEmitter subscriptions with keyed subscriber maps, such as `Map>` and `Map>`, and dispatch directly to the changed key. +Alternatively keep one shared listener per event and route through keyed maps, but do not add one emitter listener per response. +On end, deliver the final event, terminate and remove the matching SSE responses, close and delete the matching watcher, and clear any keyed waiters after their terminal response. +Have the browser call `EventSource.close()` on end; if the local SharedWorker solution is adopted, unsubscribe the key and close the origin stream when its subscriber set reaches zero. +Add tests asserting bounded emitter/listener counts with at least 50 live sessions and asserting watcher/SSE cleanup after end. +Do not solve this by raising or disabling `setMaxListeners`, because that preserves global O(N) fan-out and hides missing end cleanup. + +### Existing related work + +The closed upstream issue https://github.com/kunchenguid/lavish-axi/issues/171 added the ended event and read-only browser UI, but its fix stops short of closing the stream or watcher. +An all-issue keyword scan found no existing listener-bounding, bulk-end, archive, or prune issue; open issue https://github.com/kunchenguid/lavish-axi/issues/308 concerns a read-only session list. + +The local-only commit `c9f08d3cb10c68435e10d000673bc167db849bb3` already prototypes browser connection sharing with a SharedWorker. +Its retained E2E report at `data/lavish-chrome-connlimit-c1/findings.md:28-48` shows eleven tabs loading with only two Chrome sockets and working live updates. +That commit is based on older local main, is not installed, and does not by itself fix agent-poll fan-out or watcher/SSE cleanup on end. +It is useful salvage material, not current proof that upstream is fixed. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 55cfdd163d4..bb9d77f9a8e 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -276,6 +276,14 @@ "path": "VISION.md", "audience": "public-product" }, + { + "path": "data/fm-lavish-session-prune-f1/report.md", + "audience": "maintainer-verification" + }, + { + "path": "data/fm-lavish-session-prune-f1/upstream-issue-draft.md", + "audience": "maintainer-verification" + }, { "path": "docs/agent-control.md", "audience": "maintainer-architecture" diff --git a/tests/fm-lavish-session.test.sh b/tests/fm-lavish-session.test.sh index f89e2fdc73b..e34ea50b86c 100755 --- a/tests/fm-lavish-session.test.sh +++ b/tests/fm-lavish-session.test.sh @@ -95,6 +95,28 @@ fi || 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" LSOF_FILE="$TMP_ROOT/empty-lsof" @@ -156,14 +178,46 @@ PATH="$FAKE_BIN:$PATH" FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" || 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" FM_LAVISH_LSOF_FILE="$LSOF_FILE" \ + "$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=$(FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" FM_LAVISH_LSOF_FILE="$LSOF_FILE" "$ROOT/bin/fm-lavish-audit.sh" summary) assert_contains "$SUMMARY" 'total=8' "summary counts total registry rows" -assert_contains "$SUMMARY" 'open=5' "summary counts open registry rows after apply" +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=2' "summary counts ended 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 FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" FM_LAVISH_LSOF_FILE="$LSOF_FILE" \ + "$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' From cddaa8fe4c16e7a245c9fa2c26d92ac929c2641e Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 8 Sep 2026 14:17:19 +0800 Subject: [PATCH 06/12] no-mistakes(review): Captain: Lavish lifecycle safeguards repaired; focused regressions passed --- bin/fm-bearings-board.sh | 11 +- bin/fm-lavish-audit.sh | 123 ++++++++++++++----- bin/fm-lavish-lib.sh | 10 ++ bin/fm-lavish-session.sh | 132 ++++++++++++++++----- bin/fm-procevent-lavish.sh | 39 +++--- bin/fm-teardown.sh | 53 +++++---- tests/fm-bearings-board-render.test.sh | 32 ++++- tests/fm-bootstrap.test.sh | 4 +- tests/fm-captain-hold-lifecycle.test.sh | 28 ++++- tests/fm-lavish-session.test.sh | 19 +-- tests/fm-procevent-lavish-ack.test.sh | 14 ++- tests/fm-procevent-lavish-live-e2e.test.sh | 5 +- tests/fm-procevent.test.sh | 63 ++++++---- 13 files changed, 378 insertions(+), 155 deletions(-) create mode 100644 bin/fm-lavish-lib.sh diff --git a/bin/fm-bearings-board.sh b/bin/fm-bearings-board.sh index c33360b6c20..f027bc3b3c9 100755 --- a/bin/fm-bearings-board.sh +++ b/bin/fm-bearings-board.sh @@ -67,9 +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_state_dir() { printf '%s\n' "${LAVISH_STATE_FILE%/*}"; } -lavish_cli() { LAVISH_AXI_STATE_DIR="$(lavish_state_dir)" command lavish-axi "$@"; } +lavish_cli() { LAVISH_AXI_STATE_DIR="$LAVISH_STATE_DIR" command lavish-axi "$@"; } validate_payload() { # jq -e --arg schema "$BOARD_SCHEMA" ' @@ -191,6 +195,9 @@ command_build() { printf 'bound: %s\n' "$sid" if "$SCRIPT_DIR/fm-procevent.sh" list | awk 'NR > 1 { print $1 }' | grep -Fxq "$sid"; then + FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" \ + "$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" --task-id home >/dev/null \ diff --git a/bin/fm-lavish-audit.sh b/bin/fm-lavish-audit.sh index 5ddbb367c0a..69536bb73fd 100755 --- a/bin/fm-lavish-audit.sh +++ b/bin/fm-lavish-audit.sh @@ -5,6 +5,7 @@ # 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 ] @@ -42,12 +43,15 @@ AUTHORIZATION_RULING='Apply only captain-authorized ambiguous existing-path sess # 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_state_dir() { printf '%s\n' "${LAVISH_STATE_FILE%/*}"; } -lavish_cli() { LAVISH_AXI_STATE_DIR="$(lavish_state_dir)" command lavish-axi "$@"; } +lavish_cli() { LAVISH_AXI_STATE_DIR="$LAVISH_STATE_DIR" command lavish-axi "$@"; } make_homes_file() { local out=$1 registry="$FM_HOME/data/secondmates.md" line home @@ -77,11 +81,11 @@ make_homes_file() { } run_audit_node() { - local mode=$1 homes_file=$2 freeze=${3-} + local mode=$1 homes_file=$2 freeze=${3-} guard_task=${4-} guard_file=${5-} guard_key=${6-} guard_home=${7-} guard_allow_source=${8-} AUDIT_MODE="$mode" HOMES_FILE="$homes_file" FREEZE_FILE="$freeze" \ LAVISH_STATE_FILE="$LAVISH_STATE_FILE" ATTACHED_FILE="${FM_LAVISH_ATTACHED_KEYS_FILE:-}" \ - LSOF_FILE="${FM_LAVISH_LSOF_FILE:-}" LAVISH_PORT="${LAVISH_AXI_PORT:-4387}" \ 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"); @@ -108,6 +112,7 @@ 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; @@ -128,15 +133,20 @@ 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) => { +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") return []; + if (error.code === "ENOENT" && optional) return []; addInventoryError(home, `${label} is unreadable: ${error.message}`); return []; } @@ -194,8 +204,8 @@ for (const rawHome of homes) { for (const home of normalizedHomes) { const stateDir = path.join(home, "state"); const dataDir = path.join(home, "data"); - const stateNames = listDir(home, stateDir, "state directory"); - listDir(home, dataDir, "data directory"); + 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); @@ -213,6 +223,9 @@ for (const home of normalizedHomes) { 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"); @@ -270,7 +283,7 @@ for (const home of normalizedHomes) { 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}`); - sources.add(name.slice(0, -7)); + addSource(name.slice(0, -7), home); activePollRegistrations++; } @@ -319,16 +332,11 @@ if (process.env.ATTACHED_FILE) { let browserConnections = 0; try { let lsof; - if (process.env.LSOF_FILE) { - lsof = readInventoryFile("runtime", process.env.LSOF_FILE, "lsof evidence", false); - if (lsof === null) throw new Error("lsof evidence is unreadable"); - } else { - try { - lsof = cp.execFileSync("lsof", ["-nP", `-iTCP:${process.env.LAVISH_PORT}`, "-sTCP:ESTABLISHED"], {encoding:"utf8"}); - } catch (error) { - if (error.stdout === undefined) throw error; - lsof = String(error.stdout); - } + try { + lsof = cp.execFileSync("lsof", ["-nP", "-iTCP:4387", "-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) { @@ -360,8 +368,9 @@ const lastActivityFor = (row, ledgerRows) => { 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 => { +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; @@ -369,6 +378,9 @@ const validSessionRow = row => { 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"]}; @@ -386,10 +398,14 @@ const evidenceFor = row => { 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 currentOwners = currentOwnersFor(row.file); - const rowLedgers = ledgerRowsFor(row.key); - const dataOwners = dataOwnersFor(row.file); - const boardOwners = homeBoardOwnersFor(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)); @@ -401,8 +417,10 @@ const evidenceFor = row => { 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); @@ -412,12 +430,19 @@ const evidenceFor = row => { 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 (sources.has(sid)) evidence.push("registered-process-event-source"); 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("home-durable-review:")) || currentOwners.length || rowLedgers.some(owner => currentOwners.some(item => item.home === owner.home && item.task === owner.task_id)) || boardOwners.length || [...rowLedgers, ...dataOwners].some(owner => held.has(ownerKey(owner.home, owner.task_id || owner.task)))) return {classification:"preserve",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"]}; @@ -453,6 +478,13 @@ for (const row of rows) { 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`); } @@ -496,6 +528,33 @@ cmd_summary() { 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" + 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 ' @@ -553,6 +612,9 @@ if (process.env.AUTHORITY_FILE) { 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); @@ -614,11 +676,9 @@ cmd_apply() { $(LINE="$line" node -e 'const r=JSON.parse(process.env.LINE); process.stdout.write([r.key,r.file,r.url||"",r.status,r.updated_at||"",r.authorization||""].join("\t"))') EOF [ -n "$key" ] || die "apply queue contains an unsupported row" - if [ $((processed % batch)) -eq 0 ]; then - audit_output=$(FM_LAVISH_IDLE_EXPIRY_HOURS="$expiry" FM_LAVISH_PRESERVE_PATHS_FILE="$preserve_paths" \ - run_audit_node audit "$homes") \ - || die "could not reclassify frozen candidate: $key" - fi + audit_output=$(FM_LAVISH_IDLE_EXPIRY_HOURS="$expiry" FM_LAVISH_PRESERVE_PATHS_FILE="$preserve_paths" \ + run_audit_node audit "$homes") \ + || die "could not reclassify frozen 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"));const r=(s.sessions||{})[process.env.KEY];if(!r)process.exit(1);process.stdout.write([r.file,r.url||"",r.status,r.updated_at||""].join("\t"))') \ || die "frozen candidate key is absent: $key" [ "$current" = "$file"$'\t'"$expected_url"$'\t'"$expected_status"$'\t'"$expected_updated" ] \ @@ -645,6 +705,7 @@ EOF 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 ;; 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 index 9be6f016557..1d47bc85603 100755 --- a/bin/fm-lavish-session.sh +++ b/bin/fm-lavish-session.sh @@ -11,6 +11,7 @@ # 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 [] @@ -31,8 +32,12 @@ LAVISH_STATE_FILE="${FM_LAVISH_STATE_FILE:-${LAVISH_AXI_STATE_DIR:-$HOME/.lavish # 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() { @@ -46,8 +51,15 @@ canonical_file() { ledger_path() { printf '%s/%s.lavish-sessions\n' "$STATE" "$1"; } ledger_lock_path() { printf '%s/.%s.lavish-sessions.lock\n' "$STATE" "$1"; } -lavish_state_dir() { printf '%s\n' "${LAVISH_STATE_FILE%/*}"; } -lavish_cli() { LAVISH_AXI_STATE_DIR="$(lavish_state_dir)" command lavish-axi "$@"; } +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' @@ -70,6 +82,7 @@ write_registration() { 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"; } @@ -245,12 +258,17 @@ NODE ledger_rows_unlocked() { local task=$1 disposition=${2-} ledger ledger=$(ledger_path "$task") - [ -f "$ledger" ] && [ ! -L "$ledger" ] || return 0 - DISPOSITION="$disposition" LEDGER="$ledger" node <<'NODE' + 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`); @@ -262,7 +280,8 @@ ledger_rows() { local task=$1 disposition=${2-} lock ledger rows rc lock=$(ledger_lock_path "$task") ledger=$(ledger_path "$task") - [ -f "$ledger" ] && [ ! -L "$ledger" ] || return 0 + ledger_is_safe "$ledger" || return 1 + [ -f "$ledger" ] || return 0 fm_lock_acquire_wait "$lock" || return $? rows=$(ledger_rows_unlocked "$task" "$disposition") || { rc=$? @@ -276,7 +295,8 @@ ledger_rows() { touch_ledger_poll() { local task=$1 real=$2 ledger lock tmp rc ledger=$(ledger_path "$task") - [ -f "$ledger" ] && [ ! -L "$ledger" ] || return 0 + ledger_is_safe "$ledger" || return 1 + [ -f "$ledger" ] || return 0 lock=$(ledger_lock_path "$task") fm_lock_acquire_wait "$lock" || return $? tmp=$(umask 077; mktemp "$STATE/.${task}.lavish-sessions.XXXXXX") || { @@ -309,7 +329,8 @@ cmd_poll_activity() { return 0 fi for ledger in "$STATE"/*.lavish-sessions; do - [ -f "$ledger" ] && [ ! -L "$ledger" ] || continue + [ -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" || die "cannot refresh the Lavish poll activity ledger" @@ -317,37 +338,65 @@ cmd_poll_activity() { } guard_durable_end() { - local task=$1 real=$2 key=$3 allow_source=${4-} source_id result hold_status=0 session_json + 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 - [ ! -e "$STATE/procevent/$source_id.source" ] || [ "$source_id" = "$allow_source" ] \ - || die "durable Lavish review still has a registered process-event source: $source_id" - [ ! -e "$STATE/decision-bindings/$source_id.origin" ] \ - || die "durable Lavish review still has an open decision binding: $source_id" + 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" ] || continue - [ -e "${result%.result}.handled" ] \ - || die "durable Lavish review still has an unacknowledged delivery: $source_id" + [ -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 (Number(row.pending_prompts || 0) > 0 || (row.prompts || []).length > 0 || (row.layout_warnings || []).length > 0) 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 - if [ -f "$FM_HOME/data/backlog.md" ] && command -v tasks-axi >/dev/null 2>&1; then - FM_HOME="$FM_HOME" "$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 - fi + 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 - row=$(ledger_rows "$task" | awk -F '\t' -v file="$real" '$1 == file { print; exit }') + 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" } @@ -362,7 +411,7 @@ cmd_preflight_end() { 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" + guard_durable_end "$task" "$real" "$key" "$source_id" || exit 1 fi } @@ -375,7 +424,24 @@ cmd_end() { key=${row#*$'\t'}; key=${key%%$'\t'*} disposition=${row##*$'\t'} if [ "$disposition" = durable-review ]; then - guard_durable_end "$task" "$real" "$key" + 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" } @@ -401,7 +467,8 @@ cmd_remove_ledger() { [ "$#" -eq 1 ] || usage validate_task_id "$task" ledger=$(ledger_path "$task") - [ -e "$ledger" ] || return 0 + [ -e "$ledger" ] || [ -L "$ledger" ] || return 0 + ledger_is_safe "$ledger" || die "Lavish ledger is not a safe regular file: $ledger" lock=$(ledger_lock_path "$task") fm_lock_acquire_wait "$lock" || die "cannot lock the Lavish ledger for $task" remaining=$(ledger_rows_unlocked "$task") || { @@ -422,7 +489,8 @@ cmd_remove_ledger() { finalize_key_in_ledger() { local task=$1 key=$2 ledger lock tmp rc ledger=$(ledger_path "$task") - [ -f "$ledger" ] && [ ! -L "$ledger" ] || return 0 + ledger_is_safe "$ledger" || return 1 + [ -f "$ledger" ] || return 0 lock=$(ledger_lock_path "$task") fm_lock_acquire_wait "$lock" || return $? tmp=$(umask 077; mktemp "$STATE/.${task}.lavish-sessions.XXXXXX") || { @@ -449,7 +517,8 @@ cmd_finalize_key() { [ "$#" -eq 1 ] || usage [ -n "$key" ] && [[ "$key" != *$'\n'* ]] || die "Lavish key is invalid" for ledger in "$STATE"/*.lavish-sessions; do - [ -f "$ledger" ] && [ ! -L "$ledger" ] || continue + [ -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" @@ -506,6 +575,7 @@ case "${1:-}" in 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 "$@" ;; diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index 44ef66b38d8..de2b4dc0bee 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -142,12 +142,15 @@ LAVISH_STATE_FILE="${FM_LAVISH_STATE_FILE:-${LAVISH_AXI_STATE_DIR:-$HOME/.lavish . "$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_state_dir() { printf '%s\n' "${LAVISH_STATE_FILE%/*}"; } -lavish_cli() { LAVISH_AXI_STATE_DIR="$(lavish_state_dir)" command lavish-axi "$@"; } +lavish_cli() { LAVISH_AXI_STATE_DIR="$LAVISH_STATE_DIR" command lavish-axi "$@"; } # 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 @@ -179,6 +182,11 @@ cmd_arm() { 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" + if [ -n "$task" ]; then + "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$real" "$task" >/dev/null + else + "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$real" >/dev/null + fi || die "cannot record Lavish ownership for $real" # 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 @@ -190,16 +198,6 @@ cmd_arm() { "$SCRIPT_DIR/fm-procevent.sh" register lavish "$id" \ -- "$SCRIPT_DIR/fm-procevent-lavish.sh" poll "$real" || exit 1 fi - if [ "${FM_LAVISH_LEDGER_TEST_BYPASS:-0}" != 1 ]; then - if [ -n "$task" ]; then - "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$real" "$task" >/dev/null - else - "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$real" >/dev/null - fi || { - "$SCRIPT_DIR/fm-procevent.sh" retire "$id" >/dev/null 2>&1 || true - die "cannot record Lavish ownership for $real" - } - fi printf 'armed: %s\n' "$id" printf 'artifact: %s\n' "$real" } @@ -212,11 +210,12 @@ cmd_retire() { } cmd_retire_and_end() { - local task=${1-} artifact=${2-} + local task=${1-} artifact=${2-} id [ "$#" -eq 2 ] || usage "$SCRIPT_DIR/fm-lavish-session.sh" preflight-end "$task" "$artifact" || exit 1 - cmd_retire "$artifact" || exit 1 - "$SCRIPT_DIR/fm-lavish-session.sh" end "$task" "$artifact" + 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 @@ -320,12 +319,10 @@ cmd_poll() { trap "$cleanup_command; trap - $signal; kill -$signal $$" "$signal" done while :; do - if [ "${FM_LAVISH_LEDGER_TEST_BYPASS:-0}" != 1 ]; then - 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 + 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[@]}") diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 028220e4894..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. @@ -2582,7 +2584,7 @@ cleanup_firstmate_home_children() { } preflight_firstmate_home_lavish_children() { - local home=$1 sub_state child_meta child_id child_kind child_wt child_home failures='' + 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 @@ -2603,6 +2605,19 @@ preflight_firstmate_home_lavish_children() { 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 @@ -2764,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 @@ -2783,22 +2805,7 @@ 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 - # Lavish's supported end command requires the source file to still exist. - # The ledger owner therefore closes and verifies every recorded ephemeral - # review before process reaping or worktree return can remove that file. - 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 - } conclude_task_no_mistakes_run "$WT" reap_task_worktree_processes worktree "$WT" "$TASK_TMP" fi 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-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 19e951bd44e..9793c172c61 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -408,8 +408,8 @@ ROWS 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" + 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' diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index ff5dc3a9d52..fec37b2d751 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -19,7 +19,7 @@ command -v tasks-axi >/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-lavish-session.test.sh b/tests/fm-lavish-session.test.sh index e34ea50b86c..a4f87225a0c 100755 --- a/tests/fm-lavish-session.test.sh +++ b/tests/fm-lavish-session.test.sh @@ -52,6 +52,13 @@ 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" \ @@ -119,8 +126,6 @@ pass "register-auto refuses multiple matching lifecycle owners" AUDIT_HOME="$TMP_ROOT/audit-home" AUDIT_STATE="$TMP_ROOT/audit-lavish" -LSOF_FILE="$TMP_ROOT/empty-lsof" -: > "$LSOF_FILE" 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" @@ -156,7 +161,7 @@ process.stdout.write(JSON.stringify({sessions}, null, 2)); NODE FREEZE="$TMP_ROOT/candidates.jsonl" -OUT=$(FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" FM_LAVISH_LSOF_FILE="$LSOF_FILE" \ +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" @@ -170,7 +175,7 @@ assert_contains "$OUT" $'preserve\texpired-worktree\tretained-worktree-file' "re 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" FM_LAVISH_LSOF_FILE="$LSOF_FILE" \ +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" @@ -197,13 +202,13 @@ process.stdout.write(JSON.stringify({ ], }, null, 2)); NODE -PATH="$FAKE_BIN:$PATH" FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" FM_LAVISH_LSOF_FILE="$LSOF_FILE" \ +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=$(FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" FM_LAVISH_LSOF_FILE="$LSOF_FILE" "$ROOT/bin/fm-lavish-audit.sh" summary) +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" @@ -213,7 +218,7 @@ assert_contains "$SUMMARY" 'past_expiry=1' "summary counts expired preserved row pass "summary distinguishes registry counts from live connections" printf '{not-json}\n' > "$AUDIT_HOME/state/bad-owner.lavish-sessions" -if FM_HOME="$AUDIT_HOME" LAVISH_AXI_STATE_DIR="$AUDIT_STATE" FM_LAVISH_LSOF_FILE="$LSOF_FILE" \ +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 diff --git a/tests/fm-procevent-lavish-ack.test.sh b/tests/fm-procevent-lavish-ack.test.sh index 2ea83eef0e5..54d2c5b121e 100755 --- a/tests/fm-procevent-lavish-ack.test.sh +++ b/tests/fm-procevent-lavish-ack.test.sh @@ -2,7 +2,6 @@ # Behavior tests for Lavish delivery acknowledgement through the real # process-event capture path and a protocol-faithful fake lavish-axi. set -u -export FM_LAVISH_LEDGER_TEST_BYPASS=1 # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" @@ -84,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 28b58e3d462..49ad30b1918 100755 --- a/tests/fm-procevent-lavish-live-e2e.test.sh +++ b/tests/fm-procevent-lavish-live-e2e.test.sh @@ -4,7 +4,6 @@ # scratch directory, and its server uses an isolated ephemeral port. It never # invokes the globally installed lavish-axi or the shared server on port 4387. set -u -export FM_LAVISH_LEDGER_TEST_BYPASS=1 if [ "${FM_LAVISH_LIVE_E2E:-0}" != 1 ]; then echo "skip: set FM_LAVISH_LIVE_E2E=1 to run the patched Lavish capture/ACK regression" @@ -22,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= @@ -49,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 74c7386bd02..08eea31549f 100755 --- a/tests/fm-procevent.test.sh +++ b/tests/fm-procevent.test.sh @@ -11,7 +11,6 @@ # Legacy responses without delivery_id keep the older source-side loss window, # while the runner's own capture-before-announcement guarantee applies to both. set -u -export FM_LAVISH_LEDGER_TEST_BYPASS=1 # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" @@ -61,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 @@ -595,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 @@ -635,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" @@ -681,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" @@ -743,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 ] \ @@ -767,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" @@ -790,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" @@ -810,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" @@ -829,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 @@ -858,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" @@ -867,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 From 17cd18806e854fd0761b7f89ad6751a42e04a7bf Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 8 Sep 2026 15:20:04 +0800 Subject: [PATCH 07/12] no-mistakes(review): Hardened Lavish lifecycle ownership and teardown safeguards --- bin/fm-lavish-audit.sh | 14 ++++- bin/fm-lavish-session.sh | 120 +++++++++++++++++++++++++++++++------ bin/fm-procevent-lavish.sh | 59 ++++++++++++++---- 3 files changed, 161 insertions(+), 32 deletions(-) diff --git a/bin/fm-lavish-audit.sh b/bin/fm-lavish-audit.sh index 69536bb73fd..174cf9dc175 100755 --- a/bin/fm-lavish-audit.sh +++ b/bin/fm-lavish-audit.sh @@ -53,6 +53,13 @@ usage() { sed -n '2,/^set -u$/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; 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" @@ -81,9 +88,11 @@ make_homes_file() { } 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-} + 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' @@ -333,7 +342,7 @@ let browserConnections = 0; try { let lsof; try { - lsof = cp.execFileSync("lsof", ["-nP", "-iTCP:4387", "-sTCP:ESTABLISHED"], {encoding:"utf8"}); + 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); @@ -549,6 +558,7 @@ cmd_guard() { [ -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 \ diff --git a/bin/fm-lavish-session.sh b/bin/fm-lavish-session.sh index 1d47bc85603..62de3699c05 100755 --- a/bin/fm-lavish-session.sh +++ b/bin/fm-lavish-session.sh @@ -215,12 +215,12 @@ mark_ended() { local task=$1 key=$2 ledger tmp lock ledger=$(ledger_path "$task") lock=$(ledger_lock_path "$task") - [ -f "$ledger" ] && [ ! -L "$ledger" ] || die "cannot find the Lavish ledger for $task" - fm_lock_acquire_wait "$lock" || die "cannot lock the Lavish ledger for $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"; die "cannot stage the Lavish ledger"; } + || { fm_lock_release "$lock"; return 1; } KEY="$key" LEDGER="$ledger" node <<'NODE' > "$tmp" \ - || { rm -f "$tmp"; fm_lock_release "$lock"; die "cannot record the ended Lavish session"; } + || { 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; @@ -233,9 +233,43 @@ NODE if ! chmod 0600 "$tmp" || ! mv -f "$tmp" "$ledger"; then rm -f "$tmp" fm_lock_release "$lock" - die "cannot publish the ended Lavish ledger" + return 1 fi - fm_lock_release "$lock" || die "cannot release the Lavish ledger lock" + 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() { @@ -251,7 +285,12 @@ 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)" - mark_ended "$task" "$key" + 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" } @@ -296,7 +335,7 @@ 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 0 + [ -f "$ledger" ] || return 2 lock=$(ledger_lock_path "$task") fm_lock_acquire_wait "$lock" || return $? tmp=$(umask 077; mktemp "$STATE/.${task}.lavish-sessions.XXXXXX") || { @@ -306,10 +345,17 @@ touch_ledger_poll() { 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)); -for (const row of rows) if (!row.ended_at && row.artifact === process.env.ARTIFACT_REAL) row.last_polled_at = new Date().toISOString(); +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" @@ -320,12 +366,17 @@ NODE cmd_poll_activity() { local artifact=${1-} task=${2-} real ledger name + local matched=0 rc [ "$#" -ge 1 ] && [ "$#" -le 2 ] || usage - [ -f "$artifact" ] && [ ! -L "$artifact" ] || return 0 - real=$(canonical_file "$artifact") || return 0 + [ -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" || die "cannot refresh the Lavish poll activity ledger" + 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 @@ -333,8 +384,14 @@ cmd_poll_activity() { 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" || die "cannot refresh the Lavish poll activity ledger" + 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() { @@ -527,19 +584,46 @@ cmd_finalize_key() { 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 - mkdir -p "$(dirname "$durable")" || die "cannot create the durable artifact directory" - if [ -e "$durable" ]; then + 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" - cmp -s "$source_real" "$durable" || die "durable artifact already exists with different contents: $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" || die "cannot copy the artifact to durable storage" + cp -p "$source_real" "$durable_real" || die "cannot copy the artifact to durable storage" fi - durable_real=$(canonical_file "$durable") 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 diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index de2b4dc0bee..feec319ff12 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -134,6 +134,7 @@ 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 @@ -152,6 +153,22 @@ usage() { sed -n '2,/^set -u$/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; 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. @@ -170,7 +187,8 @@ cmd_source_id() { } cmd_arm() { - local artifact=${1-} id real task= + local artifact=${1-} id real task='' source_file source_published=0 + local -a poll_args [ -n "$artifact" ] || usage if [ "$#" -eq 3 ] && [ "$2" = --task-id ]; then task=$3 @@ -182,22 +200,39 @@ cmd_arm() { 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" - if [ -n "$task" ]; then - "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$real" "$task" >/dev/null + 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 - "$SCRIPT_DIR/fm-lavish-session.sh" register-auto "$real" >/dev/null - fi || die "cannot record Lavish ownership for $real" + 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. - if [ -n "$task" ]; then - "$SCRIPT_DIR/fm-procevent.sh" register lavish "$id" \ - -- "$SCRIPT_DIR/fm-procevent-lavish.sh" poll "$real" --task-id "$task" || exit 1 - else - "$SCRIPT_DIR/fm-procevent.sh" register lavish "$id" \ - -- "$SCRIPT_DIR/fm-procevent-lavish.sh" poll "$real" || exit 1 - fi printf 'armed: %s\n' "$id" printf 'artifact: %s\n' "$real" } From 5a379781112932777c8cb1cac044d6caf132ac07 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 8 Sep 2026 16:48:35 +0800 Subject: [PATCH 08/12] no-mistakes(test): Fixed changed-test mapping and Lavish teardown fixture regression --- bin/fm-test-run.sh | 6 ++++++ tests/fm-gotmp.test.sh | 10 ++++++++++ tests/fm-test-run.test.sh | 22 ++++++++++++++++++++++ 3 files changed, 38 insertions(+) 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/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" <"$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 From 83dc17f6ce0006dc54661925fdc59786c21d7fe7 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 8 Sep 2026 17:15:21 +0800 Subject: [PATCH 09/12] no-mistakes(document): Update Lavish toolbelt documentation --- docs/scripts.md | 4 ++++ 1 file changed, 4 insertions(+) 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 | From b7b1ec9821ea5ce6d92f953078830fd4beb33774 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 8 Sep 2026 17:19:42 +0800 Subject: [PATCH 10/12] no-mistakes(lint): Fix ShellCheck assignment expansion warning --- bin/fm-bearings-board.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bin/fm-bearings-board.sh b/bin/fm-bearings-board.sh index f027bc3b3c9..3c0092a41d9 100755 --- a/bin/fm-bearings-board.sh +++ b/bin/fm-bearings-board.sh @@ -144,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" @@ -195,7 +195,8 @@ command_build() { printf 'bound: %s\n' "$sid" if "$SCRIPT_DIR/fm-procevent.sh" list | awk 'NR > 1 { print $1 }' | grep -Fxq "$sid"; then - FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" \ + 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" From 70eebec5d99ddc9055fbcc21cdf34acef8b2820a Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 8 Sep 2026 17:45:39 +0800 Subject: [PATCH 11/12] no-mistakes(ci): Fixed the Repo invariants failure by removing the three private Lavish evidence files from the index and deleting their .gitignore unignore rules. Local evidence remains intact and ignored. Verified the exact invariant exits 0, evidence parity passes, and git diff --check passes. No other no-mistakes phase was run --- .gitignore | 3 - .../authorized-2026-09-08.json | 1660 ----------------- data/fm-lavish-session-prune-f1/report.md | 62 - .../upstream-issue-draft.md | 53 - 4 files changed, 1778 deletions(-) delete mode 100644 data/fm-lavish-session-prune-f1/authorized-2026-09-08.json delete mode 100644 data/fm-lavish-session-prune-f1/report.md delete mode 100644 data/fm-lavish-session-prune-f1/upstream-issue-draft.md diff --git a/.gitignore b/.gitignore index f0e8ea776ca..320eb3b897c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,6 @@ state/ data/* !data/fm-lavish-session-prune-f1/ data/fm-lavish-session-prune-f1/* -!data/fm-lavish-session-prune-f1/report.md -!data/fm-lavish-session-prune-f1/authorized-2026-09-08.json -!data/fm-lavish-session-prune-f1/upstream-issue-draft.md scratchpad* .no-mistakes/ .lavish/ diff --git a/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json b/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json deleted file mode 100644 index bf22b00e661..00000000000 --- a/data/fm-lavish-session-prune-f1/authorized-2026-09-08.json +++ /dev/null @@ -1,1660 +0,0 @@ -{ - "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": [ - { - "key": "00348516af86abfa", - "file": "/Users/ivan/Projects/firstmate/data/carenbloom-loewe-page-w8/index.html", - "url": "http://127.0.0.1:4387/session/00348516af86abfa", - "status": "open", - "updated_at": "2026-07-30T22:52:23.950Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "00f4d431d1c83ce1", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-4-script-fm.html", - "url": "http://127.0.0.1:4387/session/00f4d431d1c83ce1", - "status": "open", - "updated_at": "2026-08-14T10:13:30.303Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "01e03d8567965e87", - "file": "/Users/ivan/Projects/firstmate/data/recess-upright-char-r8/gallery.html", - "url": "http://127.0.0.1:4387/session/01e03d8567965e87", - "status": "open", - "updated_at": "2026-07-29T09:32:52.757Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "063fea78b0ab2d22", - "file": "/Users/ivan/Projects/firstmate/data/idel-cute-motion-economy-opus-u6/review.html", - "url": "http://127.0.0.1:4387/session/063fea78b0ab2d22", - "status": "open", - "updated_at": "2026-08-01T15:04:40.001Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "06a184da28538f77", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-1-script.html", - "url": "http://127.0.0.1:4387/session/06a184da28538f77", - "status": "open", - "updated_at": "2026-08-16T07:02:08.907Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "084e8ab0e4205b60", - "file": "/Users/ivan/Projects/firstmate/data/memory-review-2026-08-21/.lavish/memory-review.html", - "url": "http://127.0.0.1:4387/session/084e8ab0e4205b60", - "status": "open", - "updated_at": "2026-08-21T08:55:58.091Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "0dfd0a2261dc23a7", - "file": "/Users/ivan/Projects/firstmate/data/pilo-dirtytalk-game-campaign-opus-m8/slice.html", - "url": "http://127.0.0.1:4387/session/0dfd0a2261dc23a7", - "status": "open", - "updated_at": "2026-08-03T00:17:53.270Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "0e87a0302e8ff079", - "file": "/Users/ivan/Projects/firstmate/data/idel-key-screen-storyboard-b7/review.html", - "url": "http://127.0.0.1:4387/session/0e87a0302e8ff079", - "status": "open", - "updated_at": "2026-08-03T00:13:33.595Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "0f1cd9e645439376", - "file": "/Users/ivan/Projects/firstmate/data/pilo-duo-screens-v3/prototype/l1-game-v3.html", - "url": "http://127.0.0.1:4387/session/0f1cd9e645439376", - "status": "open", - "updated_at": "2026-07-30T14:57:49.105Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "1221f67357c63017", - "file": "/Users/ivan/Projects/firstmate/data/idel-yard-mission-p0-k6/review.html", - "url": "http://127.0.0.1:4387/session/1221f67357c63017", - "status": "open", - "updated_at": "2026-08-02T03:07:07.003Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "12ff2d553931ee07", - "file": "/Users/ivan/Projects/firstmate/data/recess-hype-articles-w9/review.html", - "url": "http://127.0.0.1:4387/session/12ff2d553931ee07", - "status": "open", - "updated_at": "2026-08-03T00:13:35.429Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "133ab26ac419a4d4", - "file": "/Users/ivan/Projects/firstmate/data/pilo-day2-floorseq-family-a1/out/board.html", - "url": "http://127.0.0.1:4387/session/133ab26ac419a4d4", - "status": "open", - "updated_at": "2026-08-26T04:48:50.703Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "13ed28a6b0916ba9", - "file": "/Users/ivan/Projects/firstmate/data/recess-cover-lander-k4/index.html", - "url": "http://127.0.0.1:4387/session/13ed28a6b0916ba9", - "status": "open", - "updated_at": "2026-07-30T13:25:51.537Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "14fcd73e42191483", - "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/dino.html", - "url": "http://127.0.0.1:4387/session/14fcd73e42191483", - "status": "open", - "updated_at": "2026-08-19T09:43:19.185Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "156dadaa56f17869", - "file": "/Users/ivan/Projects/firstmate/data/pilo-day2-curtains-family-a1/out/board.html", - "url": "http://127.0.0.1:4387/session/156dadaa56f17869", - "status": "open", - "updated_at": "2026-08-26T05:06:16.788Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "15bc062b38cdf572", - "file": "/Users/ivan/Projects/firstmate/data/idel-creature-hunt-fable-f5/.lavish/idel-creature-board.html", - "url": "http://127.0.0.1:4387/session/15bc062b38cdf572", - "status": "open", - "updated_at": "2026-08-20T06:01:05.953Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "16434f3d37386bb9", - "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/study.html", - "url": "http://127.0.0.1:4387/session/16434f3d37386bb9", - "status": "open", - "updated_at": "2026-08-19T10:21:05.196Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "1655e94e35a77127", - "file": "/Users/ivan/Projects/firstmate/data/recess-cover-round3-k9/gallery.html", - "url": "http://127.0.0.1:4387/session/1655e94e35a77127", - "status": "open", - "updated_at": "2026-07-29T04:06:04.286Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "18fdf1281bc94117", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-1-script-reformat.html", - "url": "http://127.0.0.1:4387/session/18fdf1281bc94117", - "status": "open", - "updated_at": "2026-08-17T06:05:25.529Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "190a8637a01b505a", - "file": "/Users/ivan/Projects/firstmate/data/idel-creature-hunt-grok-g1/.lavish/board.html", - "url": "http://127.0.0.1:4387/session/190a8637a01b505a", - "status": "open", - "updated_at": "2026-08-20T05:34:01.712Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "19dbec5d7ca1a169", - "file": "/Users/ivan/Projects/firstmate/data/carenbloom-opus-v6-o5/review.html", - "url": "http://127.0.0.1:4387/session/19dbec5d7ca1a169", - "status": "open", - "updated_at": "2026-07-31T09:27:05.487Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "223367148752378e", - "file": "/Users/ivan/Projects/firstmate/data/recess-cover-round4-p2/gallery.html", - "url": "http://127.0.0.1:4387/session/223367148752378e", - "status": "open", - "updated_at": "2026-07-29T05:31:15.462Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "22a71b146b2151a6", - "file": "/Users/ivan/Projects/firstmate/data/recess-consumer-research-v8/review.html", - "url": "http://127.0.0.1:4387/session/22a71b146b2151a6", - "status": "open", - "updated_at": "2026-08-01T15:13:33.896Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "230ffddf07168c2d", - "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/lighter.html", - "url": "http://127.0.0.1:4387/session/230ffddf07168c2d", - "status": "open", - "updated_at": "2026-08-20T01:19:03.011Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "23c0043ecbb278e9", - "file": "/Users/ivan/Projects/pvs-ideation-r3-fable/.lavish/hearth/index.html", - "url": "http://127.0.0.1:4387/session/23c0043ecbb278e9", - "status": "open", - "updated_at": "2026-08-11T01:55:49.959Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "23d3a30c7340702c", - "file": "/Users/ivan/Projects/firstmate/.lavish/review-first-2026-08-16.html", - "url": "http://127.0.0.1:4387/session/23d3a30c7340702c", - "status": "open", - "updated_at": "2026-08-16T03:18:49.465Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "26e3a752f33d6000", - "file": "/Users/ivan/Projects/firstmate/data/recess-viz-nightstand-anchor-v4/index.html", - "url": "http://127.0.0.1:4387/session/26e3a752f33d6000", - "status": "open", - "updated_at": "2026-08-09T07:39:34.636Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "27e18771d215da87", - "file": "/Users/ivan/Projects/firstmate/data/carenbloom-furnace-v3-l7/index.html", - "url": "http://127.0.0.1:4387/session/27e18771d215da87", - "status": "open", - "updated_at": "2026-07-30T23:51:10.669Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "2d13fba848ae2651", - "file": "/Users/ivan/Projects/firstmate/data/recess-tc-key-images-k7/articles-review.html", - "url": "http://127.0.0.1:4387/session/2d13fba848ae2651", - "status": "open", - "updated_at": "2026-07-31T05:17:06.735Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "2d1d9615178e1acb", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-full-read.html", - "url": "http://127.0.0.1:4387/session/2d1d9615178e1acb", - "status": "open", - "updated_at": "2026-08-17T09:07:23.157Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "2d41f8af740465e6", - "file": "/Users/ivan/Projects/firstmate/data/recess-beautiful-object-y5/gallery.html", - "url": "http://127.0.0.1:4387/session/2d41f8af740465e6", - "status": "open", - "updated_at": "2026-07-29T14:11:43.559Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "2f67255d4bd1bff2", - "file": "/Users/ivan/Projects/firstmate/data/pilo-dirtytalk-reorder-blocker-r5/review.html", - "url": "http://127.0.0.1:4387/session/2f67255d4bd1bff2", - "status": "open", - "updated_at": "2026-07-31T08:43:23.642Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "2fe3448f4d09d868", - "file": "/Users/ivan/Projects/firstmate/data/pilo-quiz-opening-creative-f1/prototype/index.html", - "url": "http://127.0.0.1:4387/session/2fe3448f4d09d868", - "status": "open", - "updated_at": "2026-08-14T11:30:30.331Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "32cef3e82b816286", - "file": "/Users/ivan/Projects/firstmate/data/pilo-eve-walk-fix-x1/out/review/full-dr-dl/eve-walk-set-review.html", - "url": "http://127.0.0.1:4387/session/32cef3e82b816286", - "status": "open", - "updated_at": "2026-08-26T05:40:53.265Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "33e1888c82408fdb", - "file": "/Users/ivan/Projects/firstmate/data/secondmate-model-eval-bv-e1/.lavish/model-scorecard.html", - "url": "http://127.0.0.1:4387/session/33e1888c82408fdb", - "status": "open", - "updated_at": "2026-08-31T08:30:13.075Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "3ac79d1211ec55fa", - "file": "/Users/ivan/Projects/firstmate/data/recess-calcube-r9/gallery.html", - "url": "http://127.0.0.1:4387/session/3ac79d1211ec55fa", - "status": "open", - "updated_at": "2026-07-29T09:58:48.090Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "3b0fdd794d1497fe", - "file": "/Users/ivan/Projects/firstmate/data/blockvalley-viewport-mock/mock.html", - "url": "http://127.0.0.1:4387/session/3b0fdd794d1497fe", - "status": "open", - "updated_at": "2026-08-28T13:00:49.169Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "3b841d7af79c0d2d", - "file": "/Users/ivan/Projects/firstmate/data/recess-viz-table-season-v1/page/index.html", - "url": "http://127.0.0.1:4387/session/3b841d7af79c0d2d", - "status": "open", - "updated_at": "2026-08-09T07:45:30.870Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "3d0376b403e59507", - "file": "/Users/ivan/Projects/firstmate/data/carenbloom-b-content-audit-r5/review.html", - "url": "http://127.0.0.1:4387/session/3d0376b403e59507", - "status": "open", - "updated_at": "2026-07-30T01:03:23.338Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "3dab4dd7f6a6cfa9", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-day-3-blind-v1.html", - "url": "http://127.0.0.1:4387/session/3dab4dd7f6a6cfa9", - "status": "open", - "updated_at": "2026-08-17T02:41:34.452Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "4062caa64f5a4b1d", - "file": "/Users/ivan/Projects/firstmate/data/recess-viz-hearth-shelf-v3/page/hearth-shelf.html", - "url": "http://127.0.0.1:4387/session/4062caa64f5a4b1d", - "status": "open", - "updated_at": "2026-08-09T07:45:17.704Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "47bd0fd338dd7159", - "file": "/Users/ivan/Projects/firstmate/data/pilo-l1-day1-assets-e1/figma-export/bed-1-set-pilot-review.html", - "url": "http://127.0.0.1:4387/session/47bd0fd338dd7159", - "status": "open", - "updated_at": "2026-08-25T03:11:49.630Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "491ba7c3fa2a5640", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-5-script-k3.html", - "url": "http://127.0.0.1:4387/session/491ba7c3fa2a5640", - "status": "open", - "updated_at": "2026-08-15T00:11:20.890Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "4d308ec1d4c3c744", - "file": "/Users/ivan/Projects/firstmate/data/kin-look-screens-f5/.lavish/board.html", - "url": "http://127.0.0.1:4387/session/4d308ec1d4c3c744", - "status": "open", - "updated_at": "2026-08-19T02:19:12.699Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "4d45fffb588fdb64", - "file": "/Users/ivan/Projects/firstmate/data/recess-nightsolo-proto-r7/gallery.html", - "url": "http://127.0.0.1:4387/session/4d45fffb588fdb64", - "status": "open", - "updated_at": "2026-07-29T09:10:41.926Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "4f72b1c1a809b6b3", - "file": "/Users/ivan/Projects/firstmate/data/omawild-home-chain-f1/index.html", - "url": "http://127.0.0.1:4387/session/4f72b1c1a809b6b3", - "status": "open", - "updated_at": "2026-09-06T01:51:38.045Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "50e08b1eab033a8d", - "file": "/Users/ivan/Projects/firstmate/data/recess-streak-object-d4/gallery.html", - "url": "http://127.0.0.1:4387/session/50e08b1eab033a8d", - "status": "open", - "updated_at": "2026-08-03T00:13:30.149Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "516e72ed74190eb2", - "file": "/Users/ivan/Projects/firstmate/data/pilo-eve-sweep-cast-x1/out/review/eve-hand-sweep-motion-review.html", - "url": "http://127.0.0.1:4387/session/516e72ed74190eb2", - "status": "open", - "updated_at": "2026-08-26T04:50:47.478Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "52030c8f8b333e7b", - "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/spine.html", - "url": "http://127.0.0.1:4387/session/52030c8f8b333e7b", - "status": "open", - "updated_at": "2026-08-20T00:48:10.124Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "52f131b4c177a5ed", - "file": "/Users/ivan/Projects/firstmate/data/blockvalley-ux-v1/board.html", - "url": "http://127.0.0.1:4387/session/52f131b4c177a5ed", - "status": "open", - "updated_at": "2026-08-28T13:19:57.607Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "5697268fb6701a5c", - "file": "/Users/ivan/Projects/firstmate/data/recess-t1-deepen-renders-m6/gallery.html", - "url": "http://127.0.0.1:4387/session/5697268fb6701a5c", - "status": "open", - "updated_at": "2026-07-29T03:13:37.690Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "5984d98e57bec724", - "file": "/Users/ivan/Projects/firstmate/data/pilo-dirtytalk-science-audit-s7/review.html", - "url": "http://127.0.0.1:4387/session/5984d98e57bec724", - "status": "open", - "updated_at": "2026-07-31T07:41:56.272Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "5a2e0444b89a32f5", - "file": "/Users/ivan/Projects/firstmate/data/pilo-day1-assets-board/index.html", - "url": "http://127.0.0.1:4387/session/5a2e0444b89a32f5", - "status": "open", - "updated_at": "2026-08-28T01:24:38.819Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "5a7905a99092492d", - "file": "/Users/ivan/Projects/firstmate/data/recess-cover-round2-b7/gallery.html", - "url": "http://127.0.0.1:4387/session/5a7905a99092492d", - "status": "open", - "updated_at": "2026-07-29T03:43:41.236Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "5e23d01a556cc897", - "file": "/Users/ivan/Projects/firstmate/data/carenbloom-pb-chip-icons-opus-i1/contact-sheet.html", - "url": "http://127.0.0.1:4387/session/5e23d01a556cc897", - "status": "open", - "updated_at": "2026-08-05T00:37:04.043Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "5e8ea550cdf8ce0c", - "file": "/Users/ivan/Projects/firstmate/.lavish/bearings-board.html", - "url": "http://127.0.0.1:4387/session/5e8ea550cdf8ce0c", - "status": "open", - "updated_at": "2026-08-24T01:25:07.120Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "629e597525c878e8", - "file": "/Users/ivan/Projects/firstmate/data/herdr-hermes-phone-setup-j5/review.html", - "url": "http://127.0.0.1:4387/session/629e597525c878e8", - "status": "open", - "updated_at": "2026-08-01T01:10:46.850Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "63358c163c2d9d58", - "file": "/Users/ivan/Projects/firstmate/data/carenbloom-bloom-anchor-h4/gallery.html", - "url": "http://127.0.0.1:4387/session/63358c163c2d9d58", - "status": "open", - "updated_at": "2026-07-30T10:03:28.039Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "676a7ac1da0ebe3d", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-day-4-script.html", - "url": "http://127.0.0.1:4387/session/676a7ac1da0ebe3d", - "status": "open", - "updated_at": "2026-08-17T07:02:03.818Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "67e2d8af12deded0", - "file": "/Users/ivan/Projects/firstmate/data/pilo-eve-walk-fix-x1/out/review/dr-cleanup-only/eve-dr-cleanup-review.html", - "url": "http://127.0.0.1:4387/session/67e2d8af12deded0", - "status": "open", - "updated_at": "2026-08-26T02:56:09.152Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "69603190944cb435", - "file": "/Users/ivan/Projects/firstmate/data/idel-onboarding-screens-opus-t5/review.html", - "url": "http://127.0.0.1:4387/session/69603190944cb435", - "status": "open", - "updated_at": "2026-08-01T13:49:23.644Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "6997e44e73e94489", - "file": "/Users/ivan/Projects/firstmate/data/recess-leader-product-f5/.lavish/board.html", - "url": "http://127.0.0.1:4387/session/6997e44e73e94489", - "status": "open", - "updated_at": "2026-08-24T02:08:30.585Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "6b08d02dd8b4d0b5", - "file": "/Users/ivan/Projects/firstmate/data/pilo-duolingo-animation-opus-v7/review.html", - "url": "http://127.0.0.1:4387/session/6b08d02dd8b4d0b5", - "status": "open", - "updated_at": "2026-08-02T03:27:15.579Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "6b51f4be99ab9c8d", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-story.html", - "url": "http://127.0.0.1:4387/session/6b51f4be99ab9c8d", - "status": "open", - "updated_at": "2026-08-17T06:35:24.082Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "6fd453221275826d", - "file": "/Users/ivan/Projects/firstmate/data/omawild-home-expedition-f2/index.html", - "url": "http://127.0.0.1:4387/session/6fd453221275826d", - "status": "open", - "updated_at": "2026-09-06T03:00:26.353Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "71dc17f0cad104e8", - "file": "/Users/ivan/Projects/firstmate/data/idel-look-ab-board-o5/.lavish/board.html", - "url": "http://127.0.0.1:4387/session/71dc17f0cad104e8", - "status": "open", - "updated_at": "2026-08-22T06:07:48.139Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "7576729b50ec3670", - "file": "/Users/ivan/Projects/firstmate/data/pilo-dirtytalk-script-v2-o5/review.html", - "url": "http://127.0.0.1:4387/session/7576729b50ec3670", - "status": "open", - "updated_at": "2026-08-03T00:13:33.882Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "75dcf745aadddda2", - "file": "/Users/ivan/Projects/firstmate/data/pilo-l1-gap-proof-r1/.lavish/index.html", - "url": "http://127.0.0.1:4387/session/75dcf745aadddda2", - "status": "open", - "updated_at": "2026-08-25T09:24:29.404Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "77934c2dd48ec086", - "file": "/Users/ivan/Projects/firstmate/data/toy-points-review/points.html", - "url": "http://127.0.0.1:4387/session/77934c2dd48ec086", - "status": "open", - "updated_at": "2026-08-11T07:55:10.110Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "78640ebb2576c28b", - "file": "/Users/ivan/Projects/pvs-ideation-r3-fable/.lavish/brief/index.html", - "url": "http://127.0.0.1:4387/session/78640ebb2576c28b", - "status": "open", - "updated_at": "2026-08-11T03:25:16.867Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "78a5312713282776", - "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/worlds.html", - "url": "http://127.0.0.1:4387/session/78a5312713282776", - "status": "open", - "updated_at": "2026-08-19T10:51:15.603Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "7999b176973e5803", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-day-2-blind-v2.html", - "url": "http://127.0.0.1:4387/session/7999b176973e5803", - "status": "open", - "updated_at": "2026-08-17T02:53:11.202Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "7a543cce5f00a279", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-review.html", - "url": "http://127.0.0.1:4387/session/7a543cce5f00a279", - "status": "open", - "updated_at": "2026-08-16T06:47:10.690Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "7e083bade2bd7340", - "file": "/Users/ivan/Projects/firstmate/data/recess-calcube-r10-x4/gallery.html", - "url": "http://127.0.0.1:4387/session/7e083bade2bd7340", - "status": "open", - "updated_at": "2026-07-29T10:42:28.783Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "8111cd15f145583c", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-4-script-k3.html", - "url": "http://127.0.0.1:4387/session/8111cd15f145583c", - "status": "open", - "updated_at": "2026-08-15T00:11:11.958Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "85ca93b6d32d62b8", - "file": "/Users/ivan/Projects/firstmate/data/omawild-madeinmay-v2-m8/index.html", - "url": "http://127.0.0.1:4387/session/85ca93b6d32d62b8", - "status": "open", - "updated_at": "2026-07-31T00:07:00.317Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "85cdb2e77ba73904", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-days.html", - "url": "http://127.0.0.1:4387/session/85cdb2e77ba73904", - "status": "open", - "updated_at": "2026-08-13T07:15:14.423Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "8692fd4d2425fa04", - "file": "/Users/ivan/Projects/firstmate/data/pilo-map-regeneration-review/.lavish/location-1-map-assets.html", - "url": "http://127.0.0.1:4387/session/8692fd4d2425fa04", - "status": "open", - "updated_at": "2026-08-20T01:18:41.365Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "8d1ff022150f01a3", - "file": "/Users/ivan/Projects/firstmate/data/recess-strategic-review-x9/review.html", - "url": "http://127.0.0.1:4387/session/8d1ff022150f01a3", - "status": "open", - "updated_at": "2026-07-30T01:22:06.802Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "9148f6b99feb9def", - "file": "/Users/ivan/Projects/firstmate/data/idel-market-ux-audit-x7/.lavish/idel-market-readiness/index.html", - "url": "http://127.0.0.1:4387/session/9148f6b99feb9def", - "status": "open", - "updated_at": "2026-08-03T00:13:33.593Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "926e41fe9035b3ab", - "file": "/Users/ivan/Projects/firstmate/data/kin-look-sky-k3/.lavish/board.html", - "url": "http://127.0.0.1:4387/session/926e41fe9035b3ab", - "status": "open", - "updated_at": "2026-08-19T04:48:59.937Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "9315e87b6ede7f43", - "file": "/Users/ivan/Projects/firstmate/data/idel-funnel-screens-c1/.lavish/look.html", - "url": "http://127.0.0.1:4387/session/9315e87b6ede7f43", - "status": "open", - "updated_at": "2026-08-19T00:09:47.678Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "9348a7e9d53a422c", - "file": "/Users/ivan/Projects/firstmate/data/recess-alarm-design-v6/gallery.html", - "url": "http://127.0.0.1:4387/session/9348a7e9d53a422c", - "status": "open", - "updated_at": "2026-07-30T07:10:32.409Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "95d84bc5853e4d8b", - "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/refs.html", - "url": "http://127.0.0.1:4387/session/95d84bc5853e4d8b", - "status": "open", - "updated_at": "2026-08-19T13:16:01.250Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "98684925b1dbfa45", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/Location-1-day-3-script-k3.html", - "url": "http://127.0.0.1:4387/session/98684925b1dbfa45", - "status": "open", - "updated_at": "2026-08-14T09:56:54.448Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "98af896703b64cd3", - "file": "/Users/ivan/Projects/firstmate/data/pilo-consumer-games-prototype-fable-m12/review.html", - "url": "http://127.0.0.1:4387/session/98af896703b64cd3", - "status": "open", - "updated_at": "2026-08-03T00:13:30.317Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "9c95dfa64a0d2a69", - "file": "/Users/ivan/Projects/firstmate/data/recess-viz-combined-surface-c1/page/index.html", - "url": "http://127.0.0.1:4387/session/9c95dfa64a0d2a69", - "status": "open", - "updated_at": "2026-08-09T08:01:22.993Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "9f7ce51140dd6c47", - "file": "/Users/ivan/Projects/firstmate/data/pilo-quiz-combat-reports/.lavish/quiz-combat-board.html", - "url": "http://127.0.0.1:4387/session/9f7ce51140dd6c47", - "status": "open", - "updated_at": "2026-08-20T14:30:44.143Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "a67f03b24cc611d5", - "file": "/Users/ivan/Projects/firstmate/data/carenbloom-furnace-v2-h6/index.html", - "url": "http://127.0.0.1:4387/session/a67f03b24cc611d5", - "status": "open", - "updated_at": "2026-07-31T00:27:15.895Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "a6f12e7004f554ff", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-day-3-script.html", - "url": "http://127.0.0.1:4387/session/a6f12e7004f554ff", - "status": "open", - "updated_at": "2026-08-17T06:35:24.287Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "a757f9e7e96e2251", - "file": "/Users/ivan/Projects/firstmate/data/pilo-arc-synthesis-f3/review/index.html", - "url": "http://127.0.0.1:4387/session/a757f9e7e96e2251", - "status": "open", - "updated_at": "2026-08-05T09:27:31.682Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "aa486da0a4e28314", - "file": "/Users/ivan/Projects/firstmate/data/recess-round6-resize-g4/gallery.html", - "url": "http://127.0.0.1:4387/session/aa486da0a4e28314", - "status": "open", - "updated_at": "2026-08-03T00:13:30.234Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "ab40edc06f73fe03", - "file": "/Users/ivan/Projects/firstmate/data/recess-round5b-pin1fix-s7/gallery.html", - "url": "http://127.0.0.1:4387/session/ab40edc06f73fe03", - "status": "open", - "updated_at": "2026-07-29T07:50:09.518Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "abae2c960c0a91fd", - "file": "/Users/ivan/Projects/pvs-ideation-r3-fable/.lavish/report.html", - "url": "http://127.0.0.1:4387/session/abae2c960c0a91fd", - "status": "open", - "updated_at": "2026-08-10T12:57:09.227Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "abd0d67cbcfbc017", - "file": "/Users/ivan/Projects/firstmate/data/carenbloom-furnace-v4-r4/index.html", - "url": "http://127.0.0.1:4387/session/abd0d67cbcfbc017", - "status": "open", - "updated_at": "2026-07-31T05:03:40.004Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "ad4434b12072616e", - "file": "/Users/ivan/Projects/firstmate/data/idel-look-screens-k3/.lavish/look.html", - "url": "http://127.0.0.1:4387/session/ad4434b12072616e", - "status": "open", - "updated_at": "2026-08-18T13:39:27.921Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "b4106cba9b7dae2f", - "file": "/Users/ivan/Projects/firstmate/data/pilo-l1-day1-assets-e1/day1-status-board.html", - "url": "http://127.0.0.1:4387/session/b4106cba9b7dae2f", - "status": "open", - "updated_at": "2026-08-25T01:51:44.983Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "b820228d99fcb230", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/script-governing.html", - "url": "http://127.0.0.1:4387/session/b820228d99fcb230", - "status": "open", - "updated_at": "2026-08-16T06:39:49.123Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "b8d98e551b679502", - "file": "/Users/ivan/Projects/firstmate/data/idel-claude-design-o5/creature.html", - "url": "http://127.0.0.1:4387/session/b8d98e551b679502", - "status": "open", - "updated_at": "2026-08-19T14:26:25.419Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "bea3aa9908f5818d", - "file": "/Users/ivan/Projects/firstmate/data/recess-onepager-e5/onepager.html", - "url": "http://127.0.0.1:4387/session/bea3aa9908f5818d", - "status": "open", - "updated_at": "2026-07-29T07:08:01.488Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "c0c3a17a7acd0a07", - "file": "/Users/ivan/Projects/firstmate/data/pilo-chaise-authored-a1/out/board.html", - "url": "http://127.0.0.1:4387/session/c0c3a17a7acd0a07", - "status": "open", - "updated_at": "2026-08-26T04:53:16.368Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "c2ee188626dc7df0", - "file": "/Users/ivan/Projects/firstmate/data/idel-motion-creative-opus-d9/motion-board.html", - "url": "http://127.0.0.1:4387/session/c2ee188626dc7df0", - "status": "open", - "updated_at": "2026-08-03T00:13:29.790Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "cabe385f6c5007a6", - "file": "/Users/ivan/Projects/firstmate/data/recess-99-day-render-p6/gallery.html", - "url": "http://127.0.0.1:4387/session/cabe385f6c5007a6", - "status": "open", - "updated_at": "2026-07-29T12:55:31.991Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "cb59980d2458124e", - "file": "/Users/ivan/Projects/firstmate/data/pilo-dirtytalk-game-campaign-opus-m8/review.html", - "url": "http://127.0.0.1:4387/session/cb59980d2458124e", - "status": "open", - "updated_at": "2026-08-03T00:13:33.780Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "cbe8d2151d1f5925", - "file": "/Users/ivan/Projects/firstmate/data/kin-look-screens-g1/.lavish/board.html", - "url": "http://127.0.0.1:4387/session/cbe8d2151d1f5925", - "status": "open", - "updated_at": "2026-08-19T05:20:39.495Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "cccb262ea6e10e0f", - "file": "/Users/ivan/Projects/firstmate/data/recess-pair-concept-h2/gallery.html", - "url": "http://127.0.0.1:4387/session/cccb262ea6e10e0f", - "status": "open", - "updated_at": "2026-07-29T13:21:01.820Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "cfcfd3fd47364241", - "file": "/Users/ivan/Projects/firstmate/data/recess-round5-refblend-h3/gallery.html", - "url": "http://127.0.0.1:4387/session/cfcfd3fd47364241", - "status": "open", - "updated_at": "2026-07-29T07:35:00.429Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "d2d54cf75b5708a6", - "file": "/Users/ivan/Projects/pvs/strategy/ideation-2026-08-k3/review.html", - "url": "http://127.0.0.1:4387/session/d2d54cf75b5708a6", - "status": "open", - "updated_at": "2026-08-10T09:25:12.122Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "d2d5fcf2f8edde38", - "file": "/Users/ivan/Projects/firstmate/data/recess-five-products/articles.html", - "url": "http://127.0.0.1:4387/session/d2d5fcf2f8edde38", - "status": "open", - "updated_at": "2026-07-31T01:09:15.623Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "d3a5ed74193c6c6e", - "file": "/Users/ivan/Projects/firstmate/data/recess-digital-detox-universe-d7/review.html", - "url": "http://127.0.0.1:4387/session/d3a5ed74193c6c6e", - "status": "open", - "updated_at": "2026-08-07T05:08:23.262Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "d3cc76ed993f5a66", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-family-review.html", - "url": "http://127.0.0.1:4387/session/d3cc76ed993f5a66", - "status": "open", - "updated_at": "2026-08-17T01:21:48.357Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "d6b62e2e545e77e1", - "file": "/Users/ivan/Projects/firstmate/data/pilo-game-structure-v1/.lavish/structure.html", - "url": "http://127.0.0.1:4387/session/d6b62e2e545e77e1", - "status": "open", - "updated_at": "2026-08-18T03:42:33.460Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "d74d2c25200ed42a", - "file": "/Users/ivan/Projects/firstmate/data/pilo-l1-day1-assets-e1/figma-export/proofs/day1-amendment8-final-board.html", - "url": "http://127.0.0.1:4387/session/d74d2c25200ed42a", - "status": "open", - "updated_at": "2026-08-25T05:57:39.036Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "d7785081978d67d2", - "file": "/Users/ivan/Projects/firstmate/data/recess-design-log-b6/logsheet.html", - "url": "http://127.0.0.1:4387/session/d7785081978d67d2", - "status": "open", - "updated_at": "2026-08-07T05:08:37.790Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "db82656675684ecb", - "file": "/Users/ivan/Projects/firstmate/data/pilo-rig-fidelity-t8/rig.html", - "url": "http://127.0.0.1:4387/session/db82656675684ecb", - "status": "open", - "updated_at": "2026-07-29T13:39:37.148Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "dcb201dc4d909dba", - "file": "/Users/ivan/Projects/firstmate/data/pilo-quiz-learnfirst-c16/.lavish/quiz-program.html", - "url": "http://127.0.0.1:4387/session/dcb201dc4d909dba", - "status": "open", - "updated_at": "2026-08-18T01:11:00.182Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "de66983011fc7ef3", - "file": "/Users/ivan/Projects/firstmate/data/recess-agency-connection-time-map-m7/review.html", - "url": "http://127.0.0.1:4387/session/de66983011fc7ef3", - "status": "open", - "updated_at": "2026-07-31T09:38:01.273Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "e5354c20992ce973", - "file": "/Users/ivan/Projects/firstmate/data/carenbloom-flower-bloom-b5/continuity-review.html", - "url": "http://127.0.0.1:4387/session/e5354c20992ce973", - "status": "open", - "updated_at": "2026-07-31T01:08:22.401Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "e562d48dbb42d80b", - "file": "/Users/ivan/Projects/firstmate/data/kin-days15-consumer-f5b/.lavish/kin-days15-consumer.html", - "url": "http://127.0.0.1:4387/session/e562d48dbb42d80b", - "status": "open", - "updated_at": "2026-08-22T05:58:58.417Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "e77092a97419998c", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-days-v2.html", - "url": "http://127.0.0.1:4387/session/e77092a97419998c", - "status": "open", - "updated_at": "2026-08-16T06:39:49.196Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "e7893ed598e4fc9e", - "file": "/Users/ivan/Projects/firstmate/data/kin-product-plan-k3/.lavish/plan.html", - "url": "http://127.0.0.1:4387/session/e7893ed598e4fc9e", - "status": "open", - "updated_at": "2026-08-19T00:39:43.614Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "ea04dcd2c5b49655", - "file": "/Users/ivan/Projects/firstmate/data/pilo-eve-r4-walk/.lavish/index.html", - "url": "http://127.0.0.1:4387/session/ea04dcd2c5b49655", - "status": "open", - "updated_at": "2026-08-25T09:56:17.087Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "ea8ead9a790b6cee", - "file": "/Users/ivan/Projects/firstmate/data/limmi-batteryless-feasibility-f8/review.html", - "url": "http://127.0.0.1:4387/session/ea8ead9a790b6cee", - "status": "open", - "updated_at": "2026-08-03T00:13:33.876Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "eb25c65d307ebe32", - "file": "/Users/ivan/Projects/firstmate/data/recess-viz-offline-league-v6/page/index.html", - "url": "http://127.0.0.1:4387/session/eb25c65d307ebe32", - "status": "open", - "updated_at": "2026-08-09T07:42:29.149Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "ebea37ae0b1fa773", - "file": "/Users/ivan/Projects/firstmate/data/idel-look-screens-f5/.lavish/look.html", - "url": "http://127.0.0.1:4387/session/ebea37ae0b1fa773", - "status": "open", - "updated_at": "2026-08-18T13:39:17.604Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "ecd8682e4bbb1da3", - "file": "/Users/ivan/Projects/firstmate/data/carenbloom-flower-bloom-b5/video-review.html", - "url": "http://127.0.0.1:4387/session/ecd8682e4bbb1da3", - "status": "open", - "updated_at": "2026-07-31T01:32:13.830Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "ef013954cbd21969", - "file": "/Users/ivan/Projects/firstmate/data/idel-funnel-screens-g1/.lavish/look.html", - "url": "http://127.0.0.1:4387/session/ef013954cbd21969", - "status": "open", - "updated_at": "2026-08-19T00:09:41.574Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "f0b8f0fd98c21197", - "file": "/Users/ivan/Projects/firstmate/data/idel-funnel-screens-k3/.lavish/look.html", - "url": "http://127.0.0.1:4387/session/f0b8f0fd98c21197", - "status": "open", - "updated_at": "2026-08-19T00:09:45.526Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "f20d0d91892fbb74", - "file": "/Users/ivan/Projects/firstmate/data/pilo-day2-chair-proving-r1/board.html", - "url": "http://127.0.0.1:4387/session/f20d0d91892fbb74", - "status": "open", - "updated_at": "2026-08-26T04:52:27.991Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "f2a44fb777baa8ec", - "file": "/Users/ivan/Projects/firstmate/data/carenbloom-furnace-page-f7/index.html", - "url": "http://127.0.0.1:4387/session/f2a44fb777baa8ec", - "status": "open", - "updated_at": "2026-07-30T23:34:09.903Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "f562dfadb2999ea8", - "file": "/Users/ivan/Projects/firstmate/data/recess-viz-stakes-ledger-v5/page/index.html", - "url": "http://127.0.0.1:4387/session/f562dfadb2999ea8", - "status": "open", - "updated_at": "2026-08-09T07:44:29.878Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "f78567ba0297cc26", - "file": "/Users/ivan/Projects/firstmate/data/recess-hype-editor-e6/review.html", - "url": "http://127.0.0.1:4387/session/f78567ba0297cc26", - "status": "open", - "updated_at": "2026-08-03T00:13:35.252Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "fa7053fd0444afcd", - "file": "/Users/ivan/Projects/firstmate/data/pilo-quiz-opening-proto-x1/prototype/index.html", - "url": "http://127.0.0.1:4387/session/fa7053fd0444afcd", - "status": "open", - "updated_at": "2026-08-14T10:02:06.383Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "fb67369082483021", - "file": "/Users/ivan/Projects/firstmate/data/idel-sprout-concept-c1/.lavish/review.html", - "url": "http://127.0.0.1:4387/session/fb67369082483021", - "status": "open", - "updated_at": "2026-08-20T12:01:07.121Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "fc783b7879f4d793", - "file": "/Users/ivan/Projects/firstmate/.lavish/three-parked-decisions-2026-07-15.html", - "url": "http://127.0.0.1:4387/session/fc783b7879f4d793", - "status": "open", - "updated_at": "2026-08-03T00:13:30.157Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "classification": "ambiguous" - }, - { - "key": "fdbf52f8fa5fc5a2", - "file": "/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-day-5-script.html", - "url": "http://127.0.0.1:4387/session/fdbf52f8fa5fc5a2", - "status": "open", - "updated_at": "2026-08-17T07:02:04.312Z", - "evidence": [ - "captain-authorized-ambiguous", - "no-positive-closed-task-owner" - ], - "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" - } - ] -} diff --git a/data/fm-lavish-session-prune-f1/report.md b/data/fm-lavish-session-prune-f1/report.md deleted file mode 100644 index 9b7a97e494a..00000000000 --- a/data/fm-lavish-session-prune-f1/report.md +++ /dev/null @@ -1,62 +0,0 @@ -# Lavish session lifecycle and conservative prune report - -## Outcome - -Firstmate now owns Lavish sessions through an explicit per-task or home ledger, teardown-time ephemeral ending, verified safe-park transfer, a narrow lifecycle-owner `retire-and-end`, a bootstrap registry diagnostic, and a dry-run-first audit/apply helper. -The captain-authorized migration verified 76 ambiguous session transitions to ended before stopping on the first contradictory result, as required. -One already-ended protected review was re-served, so the net registry change was 75 fewer open rows: 372 before and 297 after. -The target of at most 20 genuinely active open sessions was not reached because Lavish reported success without ending one session, the stop-on-contradiction contract left 60 frozen candidates unattempted, and 111 missing-path rows remain unsupported by Lavish 0.1.63. -Every ended board remains on disk and can be re-served because this migration deleted no artifact files. - -## Live migration - -The migration snapshot contained 417 total rows: 372 open, 18 feedback, and 27 ended. -An early classifier pass incorrectly treated retained Nancy direction key `6aba2ed4c6df33d3` as eligible because it recognized closed backlog rows but not their retained `hold-kind` field. -The supported `lavish-axi end ` path temporarily ended that row and verified its transition. -The retained backlog record was then found, the session was restored through supported no-open serve semantics, and the classifier was corrected and regression-tested. -The final classifier preserves that row with `retained-backlog-hold:parked` evidence. - -The captain subsequently authorized ending all 136 existing-path ambiguous rows in the frozen set except three review artifacts mentioned that day. -Those kept artifacts were Nancy tennis directions key `7f59a8c16dff9f19`, Ally paid media key `4ae99e8ad06d4a8c`, and the 食·養·打 review under `data/syd-board-b1/board/` key `cc73671c247bff78`. -The apply helper ended and verified 76 rows in batches of ten. -After 70 successful transitions, the eighth batch stopped at key `85cdb2e77ba73904` for `/Users/ivan/Projects/firstmate/data/pilo-university-game-direction-f2/.lavish/location-1-days.html` because `lavish-axi end` exited successfully but the registry row remained open. -The contradictory row was not retried, and the remaining 60 frozen candidates were not attempted. -The protected Nancy tennis directions and Ally paid-media sessions remained open. -The protected 食·養·打 review had already been ended historically, so it was re-served through supported no-open semantics and remains open. - -The final snapshot contains 417 total rows: 297 open, 18 feedback, and 102 ended. -Of the open rows, 253 were past the new 48-hour idle expiry. -The snapshot had 23 active poll registrations, ten mapped live poll clients, and three unmapped Chrome connections. -The shared server was not stopped, restarted, signalled, or reconfigured. -No Lavish record, Firstmate state record, chat, attachment, or artifact file was deleted. - -## Remaining ambiguous evidence - -The final read-only audit classified 245 rows as preserve and 172 as ambiguous. -The ambiguous set contained 61 existing artifacts with `idle-expired:48h,unmapped-browser-connections:3` evidence and 111 missing artifacts with `unsupported-by-current-Lavish,artifact-missing` evidence. -The 61 existing rows include the contradictory key plus the 60 frozen candidates left unattempted after the stop. -The 111 missing-path rows were skipped because Lavish 0.1.63 requires `realpath` of an existing file for its supported one-session end command. -No replacement file was synthesized and `state.json` was not edited. - -The final preserve set contained 102 historical ended rows plus current task ownership, retained worktrees, captain holds, decision bindings, registered or live process-event sources, feedback or prompts, unacknowledged delivery, mapped clients, unresolved layout-warning repair, and the three explicit kept boards. -Unreadable or malformed home inventory now refuses eligibility, multiple candidate owners remain ambiguous, and an unkeyed browser connection cannot be bypassed by ordinary apply. - -## Implementation and verification - -`bin/fm-lavish-session.sh` owns the locked ledger, supported end path, home-owned durable bearings board, real poll activity time, and verified ledger finalization. -`bin/fm-lavish-audit.sh` owns conservative classification, frozen candidates, the explicit 2026-09-08 authorization record, bounded apply, and read-only coverage of the primary and registered secondmate homes. -`bin/fm-procevent-lavish.sh arm` registers ownership and refreshes activity on every poll iteration. -Plain `retire` remains narrow, while `retire-and-end` preflights the durable-end guard before removing any source or binding. -`bin/fm-teardown.sh` ends and verifies task and secondmate-child ephemeral sessions before process reaping or worktree return. -`bin/fm-bootstrap.sh` stays silent below 20 open registry rows and emits one actionable warning from 50 upward while distinguishing historical rows from live connections. -Bootstrap reports the past-expiry count but never ends a session automatically. - -Behavior tests execute the public scripts against isolated Firstmate homes and Lavish state directories. -They cover ledger registration and locking, owner ambiguity, home-owned bearings, poll activity, verified end and finalization, safe-park ordering, durable-end preflight, conservative inventory failure, authorized frozen apply, secondmate-child teardown, bootstrap thresholds, and summary counts. - -## Upstream custody - -The verbatim section 6 note is in `data/fm-lavish-session-prune-f1/upstream-issue-draft.md`. -No issue or pull request was opened against `kunchenguid/lavish-axi`. -Nothing was pushed to that repository. -The draft remains parked for a later captain decision. diff --git a/data/fm-lavish-session-prune-f1/upstream-issue-draft.md b/data/fm-lavish-session-prune-f1/upstream-issue-draft.md deleted file mode 100644 index ab9e2521844..00000000000 --- a/data/fm-lavish-session-prune-f1/upstream-issue-draft.md +++ /dev/null @@ -1,53 +0,0 @@ -### Title - -`Bound live-session listeners and release SSE/watchers on end` - -### Body - -Lavish 0.1.63 uses one process-global EventEmitter and installs callbacks per live connection. -Each `/api/poll` request adds `feedback` and `ended` listeners. -Each `/events/:key` SSE connection adds `reload`, `agent-reply`, `agent-presence`, `layout-warnings`, and `ended` listeners. -The handlers filter by key after every global emit, so event cost is linear in all live connections and Node warns when the eleventh connection/listener arrives. - -Disconnect cleanup is present and works, so the warning alone should not be called a historical-session leak. -The end path is incomplete, though. -`POST /api/end` marks the session ended and emits `ended`, but it neither removes/closes the session's chokidar watcher nor terminates matching SSE responses. -The browser receives `ended` and disables its UI, but its `EventSource` remains open. -Those callbacks and the watcher survive until the tab disconnects or the whole server shuts down. - -#### Reproduction - -1. Start an isolated Lavish 0.1.63 server with isolated state. -2. Create and open eleven small HTML artifacts. -3. Hold one agent poll open for each artifact. -4. Observe `MaxListenersExceededWarning` for `feedback` and `ended` when listener eleven is registered. -5. Attach eleven SSE clients to the corresponding `/events/:key` routes. -6. Observe warnings for `reload`, `agent-reply`, `agent-presence`, `layout-warnings`, and `ended`. -7. End one session with `lavish-axi end ` while leaving its SSE client connected. -8. Observe the final `ended` event, then observe that the stream remains connected and a watcher for that key remains in the server map. -9. Emit an event for one key and observe every listener execute its key filter although only one client consumes the event. - -#### Expected - -Listener count should be bounded independently of the number of live review sessions. -Ending a session should send one final ended event, close/remove its SSE subscribers, and close/remove its file watcher. -Browser clients should close or unsubscribe from their EventSource when they enter ended state. - -#### Suggested implementation - -Replace per-connection global EventEmitter subscriptions with keyed subscriber maps, such as `Map>` and `Map>`, and dispatch directly to the changed key. -Alternatively keep one shared listener per event and route through keyed maps, but do not add one emitter listener per response. -On end, deliver the final event, terminate and remove the matching SSE responses, close and delete the matching watcher, and clear any keyed waiters after their terminal response. -Have the browser call `EventSource.close()` on end; if the local SharedWorker solution is adopted, unsubscribe the key and close the origin stream when its subscriber set reaches zero. -Add tests asserting bounded emitter/listener counts with at least 50 live sessions and asserting watcher/SSE cleanup after end. -Do not solve this by raising or disabling `setMaxListeners`, because that preserves global O(N) fan-out and hides missing end cleanup. - -### Existing related work - -The closed upstream issue https://github.com/kunchenguid/lavish-axi/issues/171 added the ended event and read-only browser UI, but its fix stops short of closing the stream or watcher. -An all-issue keyword scan found no existing listener-bounding, bulk-end, archive, or prune issue; open issue https://github.com/kunchenguid/lavish-axi/issues/308 concerns a read-only session list. - -The local-only commit `c9f08d3cb10c68435e10d000673bc167db849bb3` already prototypes browser connection sharing with a SharedWorker. -Its retained E2E report at `data/lavish-chrome-connlimit-c1/findings.md:28-48` shows eleven tabs loading with only two Chrome sockets and working live updates. -That commit is based on older local main, is not installed, and does not by itself fix agent-poll fan-out or watcher/SSE cleanup on end. -It is useful salvage material, not current proof that upstream is fixed. From dff28d93ce9806ffacca8d61a0b653d1a16c4df9 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 8 Sep 2026 18:04:24 +0800 Subject: [PATCH 12/12] no-mistakes(ci): Removed stale ignored Lavish evidence paths from docs/documentation-audiences.json. Focused checker, regression test, and git diff --check all pass; only that file changed --- docs/documentation-audiences.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index bb9d77f9a8e..55cfdd163d4 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -276,14 +276,6 @@ "path": "VISION.md", "audience": "public-product" }, - { - "path": "data/fm-lavish-session-prune-f1/report.md", - "audience": "maintainer-verification" - }, - { - "path": "data/fm-lavish-session-prune-f1/upstream-issue-draft.md", - "audience": "maintainer-verification" - }, { "path": "docs/agent-control.md", "audience": "maintainer-architecture"