From 2ea938efdd4222b71f0a4532f1b2bb628762700d Mon Sep 17 00:00:00 2001 From: mosaic-coder Date: Sat, 25 Jul 2026 19:52:25 -0500 Subject: [PATCH] =?UTF-8?q?feat(wake):=20W4=20=E2=80=94=20per-host=20delta?= =?UTF-8?q?-gated=20detector=20daemon=20(fail-loud=20source=20semantics,?= =?UTF-8?q?=20enqueues=20to=20W2=20store)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1 of the wake canon (EPIC #892). A per-host, single-instance, long-lived detector SERVICE (not a timer) that generalizes the proven delta-gated poller and enqueues observed deltas into the merged W2 durable store. - §1.1 placement: per-host single-instance (non-blocking flock; a 2nd instance refuses), serves local panes only, no cross-host waking. A long-lived service, not a timer. Generalizes the poll->hash->deliver-only-on-delta exemplar and adds (a) repo-section/anchor-scoped hashing so human-decision FILE edits are caught, not just API-visible state. - §1.2 observe->store: on a delta, assign the next observed_seq and enqueue to store.sh with {class, locators, emit_ts} via its public API (store not reimplemented). - §2.4 cursor semantics: detector-local observed_seq is a monotonic int assigned AT OBSERVATION and authoritative; source SHAs are descriptors. A per-watch last-observed hash is compared each poll so a revert (A->B->A) is caught. - §4/G2a fail-loud source semantics: a source error / 401 / 403 / privacy-404 / partial / ambiguous-empty response fails loud, does NOT advance observed_seq, and is NEVER treated as "no change". - Gate B (#869): the watch-list schema_version is validated against the manifest [schema_min, schema_max]; out of range => rejected (fail loud), never coerced. RED-first harness (test-wake-detector.sh), wired into test:framework-shell: no-change->no enqueue; change->exactly one enqueue with a fresh observed_seq; revert A->B->A caught; single-instance flock; schema out-of-range fail-loud; source error/ambiguous-empty fail-loud with observed_seq not advanced; anchor-scoped hashing (outside-anchor edit = no-op, inside-anchor edit = delta). Fake/stub sources only (no live network); shellcheck clean; operator-agnostic. Part of #892 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0158NZqN2n2ymKFeJAZ4GUCb --- .../mosaic/framework/tools/wake/detector.sh | 446 ++++++++++++++++++ .../mosaic/framework/tools/wake/manifest.txt | 10 +- .../tools/wake/test-wake-detector.sh | 340 +++++++++++++ packages/mosaic/package.json | 2 +- 4 files changed, 794 insertions(+), 4 deletions(-) create mode 100755 packages/mosaic/framework/tools/wake/detector.sh create mode 100755 packages/mosaic/framework/tools/wake/test-wake-detector.sh diff --git a/packages/mosaic/framework/tools/wake/detector.sh b/packages/mosaic/framework/tools/wake/detector.sh new file mode 100755 index 00000000..f7334f36 --- /dev/null +++ b/packages/mosaic/framework/tools/wake/detector.sh @@ -0,0 +1,446 @@ +#!/usr/bin/env bash +# detector.sh — A1 of the wake canon (EPIC #892, W4): the per-host, +# single-instance, delta-gated DETECTOR daemon. +# +# CONTRACT ANCHORS (docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md): +# §1.1 Placement — ONE detector instance PER HOST, serving that host's LOCAL +# panes only (no cross-host waking). A long-lived SERVICE, NOT a timer: +# retiring unconditional timers is the whole point. Generalizes the +# proven delta-gated poller (gitea-pr-watch: poll -> hash -> deliver only +# on delta; 0-wasted) and adds (a) repo-section/anchor-scoped hashing so +# human-decision FILE edits are caught, not just API-visible state. +# §1.2 observe -> assign observed_seq -> enqueue into the W2 durable store. +# §2.4 Cursor semantics: `observed_seq` is a detector-local monotonic int +# assigned AT OBSERVATION and is AUTHORITATIVE; source SHAs are +# DESCRIPTORS, not the cursor. A per-watch LAST-OBSERVED HASH is compared +# each poll so a revert (A->B->A across polls) is caught as a delta. +# §4/G2a FAIL-LOUD source semantics: a source/target failure OR a 401 / 403 / +# privacy-404 / partial / ambiguous-empty response MUST fail loud, MUST +# NOT advance the authoritative cursor (`observed_seq`), and MUST NOT be +# silently treated as "no change". An error, or an empty response that +# might mean "hidden", is NEVER "no change". +# Gate B (#869 discipline): the watch-list `schema_version` is validated +# against the component manifest's supported [schema_min, schema_max] +# range; an out-of-range watch-list is REJECTED (fail loud), never +# silently coerced. +# +# SCOPE: this tool OBSERVES sources and ENQUEUES deltas to the W2 store +# (store.sh, merged). It does NOT reimplement the store, ack, digest, or signer +# (W2/W3 — it only CALLS their public APIs). FN-oracle/reconciler (W5), off-host +# beacon (W6) and installer (W7) are explicitly OUT OF SCOPE. +# +# SOURCE ADAPTER CONTRACT (operator-agnostic firewall: the detector bakes in NO +# git/gitea/HTTP/file specifics — the operator supplies the adapter): +# WAKE_DETECTOR_SOURCE_CMD is invoked as: +# with the source definition JSON on STDIN. It reports the CURRENT observed +# state of that source: +# exit 0 + NON-EMPTY stdout = the raw source descriptor (bytes to hash). +# exit 0 + EMPTY stdout = AMBIGUOUS-EMPTY -> FAIL LOUD (an empty that +# might mean "hidden/403'd" is never "no change"). +# exit non-zero = source error (network failure, 401/403, +# privacy-404, partial/truncated) -> FAIL LOUD. +# The detector applies anchor-scoping (feature (a)) to the raw descriptor when +# the source defines an `anchor`, then hashes the scoped region. +# +# Operator-agnostic: all state via XDG/env; no operator paths/names/secrets/hosts. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./_wake-common.sh disable=SC1091 +. "$SCRIPT_DIR/_wake-common.sh" + +STATE_DIR="$(wake_state_dir)" +STORE_SH="$SCRIPT_DIR/store.sh" +MANIFEST="$SCRIPT_DIR/manifest.txt" + +# Detector-local state lives UNDER the store's STATE_DIR in its own subdir so it +# never collides with the store's cursor/inbox files. +DET_DIR="$STATE_DIR/detector" + +_need_jq() { + command -v jq >/dev/null 2>&1 || { + echo "detector.sh: jq is required" >&2 + exit 3 + } +} + +# _hash — sha256 of stdin, first field only (portable across sha256sum/shasum/ +# openssl). Used for the per-watch last-observed hash (delta gate). +_hash() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 | awk '{print $NF}' + else + echo "detector.sh: no sha256 tool (sha256sum/shasum/openssl) available" >&2 + exit 3 + fi +} + +usage() { + cat >&2 <<'EOF' +Usage: detector.sh [options] + +Commands: + poll-once Run EXACTLY ONE delta-gated polling pass + over every source in the watch-list. + Enqueues to the W2 store on a delta only. + Exits non-zero if ANY source failed loud + (G2a) — the failing source does NOT + advance observed_seq and is NOT treated + as "no change". + run [--once] Long-lived per-host SINGLE-INSTANCE + service: acquire a non-blocking flock + (a 2nd instance REFUSES), then loop + poll-once every WAKE_DETECTOR_INTERVAL + seconds. --once does a single guarded + cycle then exits (still flock-guarded). + validate Load + validate the watch-list and its + schema_version against the manifest; + exit 0 if usable, non-zero (loud) if not. + cursors Print the detector-local observed_seq + counter and the store cursors. + +Environment: + WAKE_WATCH_LIST Path to the operator watch-list JSON (required). + WAKE_DETECTOR_SOURCE_CMD Source adapter command (see header contract; required + for poll-once/run). + WAKE_DETECTOR_INTERVAL run-loop poll interval seconds (default 30). + WAKE_DETECTOR_LOCK flock path (default /detector/detector.lock). + WAKE_STATE_HOME/WAKE_AGENT store namespace (see store.sh). +EOF +} + +# --- watch-list load + Gate B schema-version validation -------------------- + +_manifest_val() { + # _manifest_val KEY — echo VALUE for KEY=VALUE in the manifest (blank if none). + local key="$1" + [ -f "$MANIFEST" ] || return 0 + sed -n "s/^${key}=//p" "$MANIFEST" | head -n1 | tr -d '[:space:]' +} + +# _load_watchlist — validate the watch-list path + JSON + schema_version range. +# FAIL LOUD (non-zero) on anything unusable. Echoes the validated JSON on stdout. +_load_watchlist() { + _need_jq + local wl="${WAKE_WATCH_LIST:-}" + if [ -z "$wl" ]; then + echo "detector.sh: WAKE_WATCH_LIST is not set (no watch-list to observe)" >&2 + return 2 + fi + if [ ! -f "$wl" ]; then + echo "detector.sh: watch-list not found: $wl" >&2 + return 2 + fi + local json + if ! json="$(jq -e . "$wl" 2>/dev/null)"; then + echo "detector.sh: watch-list is not valid JSON: $wl" >&2 + return 2 + fi + + # Required shape. + if ! printf '%s' "$json" | jq -e 'has("schema_version") and has("watches")' >/dev/null 2>&1; then + echo "detector.sh: watch-list missing required 'schema_version' or 'watches'" >&2 + return 2 + fi + + # Gate B: schema_version MUST be an integer inside the manifest's supported + # [schema_min, schema_max] range. Out of range => REJECT (never coerce). + local ver smin smax + ver="$(printf '%s' "$json" | jq -r '.schema_version')" + smin="$(_manifest_val schema_min)" + smax="$(_manifest_val schema_max)" + case "$ver" in + '' | *[!0-9]*) + echo "detector.sh: watch-list schema_version must be an integer (got '$ver')" >&2 + return 2 + ;; + esac + if [ -z "$smin" ] || [ -z "$smax" ]; then + echo "detector.sh: manifest is missing schema_min/schema_max (cannot validate Gate B)" >&2 + return 2 + fi + if [ "$ver" -lt "$smin" ] || [ "$ver" -gt "$smax" ]; then + echo "detector.sh: FAIL LOUD (Gate B) — watch-list schema_version $ver is OUTSIDE the supported range [$smin, $smax]; refusing to run against an incompatible watch-list" >&2 + return 2 + fi + + printf '%s' "$json" +} + +# --- detector-local observed_seq (§2.4, authoritative monotonic int) -------- + +# _next_observed_seq — atomically increment + return the detector-local counter. +# Assigned AT OBSERVATION (only on a real delta). A single global monotonic +# sequence across ALL watches (source SHAs are descriptors, NOT the cursor). +_next_observed_seq() { + mkdir -p "$DET_DIR" + local cur nxt + cur="$(_wake_read_int "$DET_DIR/observed_seq_counter" 0)" + nxt=$((cur + 1)) + printf '%s' "$nxt" | _atomic_write "$DET_DIR/observed_seq_counter" + printf '%s' "$nxt" +} + +# --- per-watch last-observed hash (§2.4 revert/ABA-at-rest detection) -------- + +_hash_file() { + # _hash_file KIND ID — deterministic per-source hash-state filename. + local kind="$1" id="$2" key + key="$(printf '%s\037%s' "$kind" "$id" | _hash)" + printf '%s/watch-%s.hash' "$DET_DIR" "$key" +} + +# --- anchor-scoped hashing (§1.1 feature (a)) ------------------------------- + +# _scope_anchor ANCHOR (raw content on stdin) — extract ONLY the region of the +# content delimited by ANCHOR: from the first line CONTAINING the anchor marker +# up to (but excluding) the next markdown-style heading line, or EOF. This lets +# a file edit OUTSIDE the lane's anchored section not wake the lane, while an +# edit INSIDE it is caught — human-decision FILE edits, not just API state. +# An empty scope (anchor not present) is surfaced to the caller as empty output. +_scope_anchor() { + local anchor="$1" + awk -v a="$anchor" ' + index($0, a) > 0 && !inzone { inzone=1; print; next } + inzone && /^#/ { exit } + inzone { print } + ' +} + +# --- one poll of one source ------------------------------------------------- + +# _poll_source KIND ID DEF_JSON CLASS — returns: +# 0 processed (delta enqueued OR no-op no-change OR silent first-seen baseline) +# 1 FAIL LOUD (source error / ambiguous-empty) — cursor + hash left UNTOUCHED +_poll_source() { + local kind="$1" id="$2" def="$3" class="$4" + local anchor + anchor="$(printf '%s' "$def" | jq -r '.anchor // empty')" + + # --- observe via the operator source adapter (fail-loud contract) --------- + # The source definition is handed to the adapter on STDIN via a temp FILE + # (not a pipe): an adapter that ignores stdin must not take SIGPIPE and read + # back as a spurious "source error" under `pipefail`. + local raw rc deftmp + mkdir -p "$DET_DIR" + # _wake_tmp_prefix is provided by _wake-common.sh (sourced above). + # shellcheck disable=SC2154 + deftmp="$(mktemp "$DET_DIR/${_wake_tmp_prefix}defXXXXXX")" || return 1 + printf '%s' "$def" >"$deftmp" + raw="$("$WAKE_DETECTOR_SOURCE_CMD" "$kind" "$id" <"$deftmp" 2>/dev/null)" + rc=$? + rm -f "$deftmp" + if [ "$rc" -ne 0 ]; then + echo "detector.sh: FAIL LOUD (G2a) — source '$kind/$id' errored (adapter exit $rc: network/401/403/privacy-404/partial). observed_seq NOT advanced; NOT treated as 'no change'." >&2 + return 1 + fi + if [ -z "$raw" ]; then + echo "detector.sh: FAIL LOUD (G2a) — source '$kind/$id' returned AMBIGUOUS-EMPTY (empty-that-might-mean-hidden is never 'no change'). observed_seq NOT advanced." >&2 + return 1 + fi + + # --- anchor-scope (feature (a)) then hash --------------------------------- + local scoped + if [ -n "$anchor" ]; then + scoped="$(printf '%s' "$raw" | _scope_anchor "$anchor")" + if [ -z "$scoped" ]; then + echo "detector.sh: FAIL LOUD (G2a) — source '$kind/$id' anchor '$anchor' not present in the observed content (ambiguous: cannot tell 'section removed' from 'hidden'). observed_seq NOT advanced." >&2 + return 1 + fi + else + scoped="$raw" + fi + + local h hf last + h="$(printf '%s' "$scoped" | _hash)" + hf="$(_hash_file "$kind" "$id")" + mkdir -p "$DET_DIR" + + if [ ! -f "$hf" ]; then + # First-seen: establish the baseline WITHOUT a wake (generalizes the + # proven deliver-on-NEW poller; startup parity is the reconciler's job, + # W5, out of scope). Flagged as a deliberate choice in the PR body. + printf '%s' "$h" | _atomic_write "$hf" + return 0 + fi + + last="$(tr -d '[:space:]' <"$hf")" + if [ "$h" = "$last" ]; then + # DELTA GATE: unchanged -> NO enqueue (0-wasted). Cursor untouched. + return 0 + fi + + # --- DELTA: assign the next observed_seq AT OBSERVATION and enqueue -------- + # (a revert A->B->A lands here because $h != the stored $last). + local seq locators emit_ts + seq="$(_next_observed_seq)" + emit_ts="$(date +%s)" + # NB: `def` is a reserved word in jq — the source-definition arg is `$sdef`. + locators="$(jq -cn \ + --arg kind "$kind" \ + --arg id "$id" \ + --argjson sdef "$def" \ + --arg hash "$h" \ + '{kind:$kind, id:$id, observed_hash:$hash} + + ( $sdef | {repo, path, anchor, remote, branches} | with_entries(select(.value != null)) )')" + + local args=(enqueue --seq "$seq" --locators "$locators" --emit-ts "$emit_ts") + # Pass class through only when the source declares one; absent => the store + # defaults to `actionable` (fail-safe, §2.3). Never guess a coalescible class. + [ -n "$class" ] && args+=(--class "$class") + if ! "$STORE_SH" "${args[@]}" >/dev/null; then + echo "detector.sh: store enqueue FAILED for '$kind/$id' seq $seq" >&2 + return 1 + fi + + # Only AFTER a durable enqueue do we advance the per-watch last-observed hash. + printf '%s' "$h" | _atomic_write "$hf" + return 0 +} + +# --- one full pass over the watch-list ------------------------------------- + +cmd_poll_once() { + local json + json="$(_load_watchlist)" || exit $? + + if [ -z "${WAKE_DETECTOR_SOURCE_CMD:-}" ]; then + echo "detector.sh: WAKE_DETECTOR_SOURCE_CMD is not set (no adapter to observe sources)" >&2 + exit 2 + fi + + _wake_init_dir "$STATE_DIR" + mkdir -p "$DET_DIR" + + # Iterate the DECLARED source-coverage inventory (§4/G3): only sources listed + # in watches[].sources[] are polled. An omitted source is not observed (and so + # cannot make anything pass vacuously); a referenced-but-undefined source is a + # malformed watch-list -> FAIL LOUD. + local pairs + pairs="$(printf '%s' "$json" | jq -r ' + [ .watches[].sources[] | "\(.kind)\t\(.id)" ] | unique | .[]')" + + if [ -z "$pairs" ]; then + echo "detector.sh: watch-list declares no sources under watches[].sources[]" >&2 + exit 2 + fi + + local failed=0 kind id def class + while IFS=$'\t' read -r kind id; do + [ -n "$kind" ] || continue + # Resolve the source definition from its top-level collection by id. + local coll + case "$kind" in + repo) coll="repos" ;; + board_file) coll="board_files" ;; + lane_anchor) coll="lane_anchors" ;; + *) + echo "detector.sh: FAIL LOUD — unknown source kind '$kind' in watch-list" >&2 + failed=1 + continue + ;; + esac + def="$(printf '%s' "$json" | jq -c --arg c "$coll" --arg id "$id" \ + '(.[$c] // []) | map(select(.id == $id)) | .[0] // empty')" + if [ -z "$def" ]; then + echo "detector.sh: FAIL LOUD (G3 parity) — watch references '$kind/$id' but no such entry is declared in '$coll'" >&2 + failed=1 + continue + fi + class="$(printf '%s' "$def" | jq -r '.class // empty')" + if ! _poll_source "$kind" "$id" "$def" "$class"; then + failed=1 + fi + done <&2 + exit 2 + ;; + esac + done + + mkdir -p "$DET_DIR" + local lock="${WAKE_DETECTOR_LOCK:-$DET_DIR/detector.lock}" + mkdir -p "$(dirname "$lock")" + + # PER-HOST SINGLE-INSTANCE (§1.1): a non-blocking exclusive flock. A second + # instance CANNOT acquire it and REFUSES loudly rather than double-observing. + exec 9>"$lock" + if ! flock -n 9; then + echo "detector.sh: FAIL LOUD — another detector instance already holds $lock; refusing (per-host single-instance)." >&2 + exit 1 + fi + # Ready marker so a supervisor/test can observe the lock is held before racing. + printf '%s\n' "$$" >"$lock.pid" 2>/dev/null || true + + local interval="${WAKE_DETECTOR_INTERVAL:-30}" + case "$interval" in + '' | *[!0-9]*) interval=30 ;; + esac + + while :; do + # A single failing source must not kill the long-lived service; poll-once + # already failed loud on stderr for it. The loop keeps serving healthy ones. + cmd_poll_once || true + [ "$once" -eq 1 ] && break + sleep "$interval" + done +} + +cmd_cursors() { + local counter + counter="$(_wake_read_int "$DET_DIR/observed_seq_counter" 0)" + printf 'detector_observed_seq=%s\n' "$counter" + "$STORE_SH" cursors 2>/dev/null || true +} + +cmd_validate() { + local json + json="$(_load_watchlist)" || exit $? + local n + n="$(printf '%s' "$json" | jq -r '[.watches[].sources[]] | length')" + echo "detector.sh: watch-list OK ($n declared source reference(s))" +} + +main() { + [ $# -ge 1 ] || { + usage + exit 2 + } + local cmd="$1" + shift + case "$cmd" in + poll-once) cmd_poll_once "$@" ;; + run) cmd_run "$@" ;; + validate) cmd_validate "$@" ;; + cursors) cmd_cursors "$@" ;; + -h | --help | help) usage ;; + *) + echo "detector.sh: unknown command '$cmd'" >&2 + usage + exit 2 + ;; + esac +} + +main "$@" diff --git a/packages/mosaic/framework/tools/wake/manifest.txt b/packages/mosaic/framework/tools/wake/manifest.txt index cd2edbc6..069c1930 100644 --- a/packages/mosaic/framework/tools/wake/manifest.txt +++ b/packages/mosaic/framework/tools/wake/manifest.txt @@ -13,8 +13,9 @@ # Component identity + semantic version. # 0.1.0 W2 — store+drain lib + ack-wrapper. # 0.2.0 W3 — cumulative-state digest renderer + non-circular HMAC signer. +# 0.3.0 W4 — per-host single-instance delta-gated detector daemon. component=wake -version=0.2.0 +version=0.3.0 # Watch-list schema this component consumes, and the INCLUSIVE range of # schema_version values it supports. A wake-watch-list.json whose schema_version @@ -31,5 +32,8 @@ schema_max=1 # two-tier trust, injection/secret scrub). (W3) # sign.sh A5 — non-circular HMAC signer (independent wake_id, # load_credentials by-name; fills the hmac placeholder). (W3) -# Out of scope (later waves): detector (W4), FN-oracle/reconciler (W5), -# beacon (W6), installer (W7). +# detector.sh A1 — per-host single-instance delta-gated detector daemon +# (flock, anchor-scoped hashing, detector-local observed_seq, +# fail-loud source semantics; enqueues deltas to store.sh). (W4) +# Out of scope (later waves): FN-oracle/reconciler (W5), beacon (W6), +# installer (W7). diff --git a/packages/mosaic/framework/tools/wake/test-wake-detector.sh b/packages/mosaic/framework/tools/wake/test-wake-detector.sh new file mode 100755 index 00000000..8b2c0660 --- /dev/null +++ b/packages/mosaic/framework/tools/wake/test-wake-detector.sh @@ -0,0 +1,340 @@ +#!/usr/bin/env bash +# test-wake-detector.sh — RED-FIRST invariant harness for W4 (EPIC #892): +# the per-host, single-instance, delta-gated DETECTOR daemon (detector.sh, A1). +# +# Each test asserts ONE CONVERGED-DESIGN invariant and is designed to go RED if +# that invariant regresses: +# D1 no-change poll -> NO enqueue (delta-gated; 0-wasted) (§1.1) +# D2 a change -> EXACTLY ONE enqueue with a FRESH observed_seq (§1.2/§2.4) +# D3 revert A->B->A across polls -> caught (delta detected) (§2.4) +# D4 single-instance flock (2nd instance REFUSES) (§1.1) +# D5 watch-list schema_version out of manifest range -> FAIL LOUD (Gate B) +# D6 source error/401/403/ambiguous-empty -> FAIL LOUD, +# observed_seq NOT advanced (the G2a invariant) (§4/G2a) +# D7 anchor-scoped hashing: edit OUTSIDE the anchor is a no-op; +# edit INSIDE the anchor is a delta (§1.1 (a)) +# +# Uses FAKE/STUB sources only (no live network). Isolated: every test runs +# against a fresh WAKE_STATE_HOME temp dir. +# +# SC2030/SC2031 are DELIBERATELY disabled: each test runs in its own ( ) subshell +# and re-exports the per-test env (source-adapter path, watch-list path) so the +# environments are isolated and cannot leak between tests. shellcheck reads the +# re-export-per-subshell idiom as "a change that might be lost" — which is +# exactly the isolation we want, not a bug. +# shellcheck disable=SC2030,SC2031 +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DET="$SCRIPT_DIR/detector.sh" +STORE="$SCRIPT_DIR/store.sh" + +command -v jq >/dev/null 2>&1 || { + echo "SKIP: jq not available" >&2 + exit 0 +} +command -v flock >/dev/null 2>&1 || { + echo "SKIP: flock not available" >&2 + exit 0 +} + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +# Failures recorded to a FILE (subshell-safe: a subshell cannot mutate a parent +# var, so a var counter would silently swallow failures — the exact anti-pattern +# this harness must never have; mirrors test-wake-store-ack.sh). +FAILFILE="$TMP_ROOT/failures" +: >"$FAILFILE" +pass=0 +fail_msg() { + echo " FAIL: $*" >&2 + echo "x" >>"$FAILFILE" +} +ok() { pass=$((pass + 1)); } + +fresh_state() { + local d="$TMP_ROOT/$1" + rm -rf "$d" + mkdir -p "$d" + printf '%s' "$d" +} + +# depth — pending_depth reported by the store for the current namespace. +depth() { "$STORE" cursors | sed -n 's/pending_depth=//p'; } +det_seq() { "$DET" cursors | sed -n 's/detector_observed_seq=//p'; } + +# write_watchlist FILE VERSION — a minimal valid watch-list with one repo source +# ("r1") plus optionally a board_file with an anchor ("b1"). +write_watchlist() { + local file="$1" ver="$2" + cat >"$file" <_, and whose exit code is read from +# $STUB_DIR/_.rc (default 0). No network. The detector invokes it as +# ` ` with the source def on stdin (ignored here). +make_stub() { + local dir="$1" + cat >"$dir/adapter.sh" < NO enqueue (delta-gated) ==" +( + WAKE_STATE_HOME="$(fresh_state d1)" + export WAKE_STATE_HOME + unset WAKE_AGENT + stub="$TMP_ROOT/d1stub" + mkdir -p "$stub" + make_stub "$stub" + export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" + wl="$TMP_ROOT/d1.json" + write_watchlist "$wl" 1 + export WAKE_WATCH_LIST="$wl" + printf 'SHA-AAA\n' >"$stub/repo_r1" + printf '## LANE-X\ndecision: hold\n' >"$stub/board_file_b1" + # Pass 1: first-seen -> baseline, NO wake. + "$DET" poll-once || fail_msg "D1: baseline pass should succeed" + [ "$(depth)" = "0" ] || fail_msg "D1: first-seen must baseline silently (deliver-on-new), got depth $(depth)" + # Pass 2: identical -> STILL no enqueue. + "$DET" poll-once || fail_msg "D1: unchanged pass should succeed" + [ "$(depth)" = "0" ] || fail_msg "D1: no-change poll must NOT enqueue, got depth $(depth)" + [ "$(det_seq)" = "0" ] || fail_msg "D1: observed_seq must not advance without a delta, got $(det_seq)" +) && ok + +echo "== D2: a change -> EXACTLY ONE enqueue with a fresh observed_seq ==" +( + WAKE_STATE_HOME="$(fresh_state d2)" + export WAKE_STATE_HOME + unset WAKE_AGENT + stub="$TMP_ROOT/d2stub" + mkdir -p "$stub" + make_stub "$stub" + export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" + wl="$TMP_ROOT/d2.json" + write_watchlist "$wl" 1 + export WAKE_WATCH_LIST="$wl" + printf 'SHA-AAA\n' >"$stub/repo_r1" + printf '## LANE-X\ndecision: hold\n' >"$stub/board_file_b1" + "$DET" poll-once >/dev/null || fail_msg "D2: baseline pass failed" # baseline both + [ "$(depth)" = "0" ] || fail_msg "D2: baseline should not enqueue" + # Change ONLY the repo source. + printf 'SHA-BBB\n' >"$stub/repo_r1" + "$DET" poll-once >/dev/null || fail_msg "D2: change pass failed" + [ "$(depth)" = "1" ] || fail_msg "D2: a single change must yield EXACTLY ONE enqueue, got depth $(depth)" + [ "$(det_seq)" = "1" ] || fail_msg "D2: observed_seq must advance to 1 on the first delta, got $(det_seq)" + # The enqueued entry carries a fresh observed_seq and the source locator. + entry="$("$DET" cursors >/dev/null; "$STORE" drain)" + echo "$entry" | jq -e 'select(.observed_seq==1 and .locators.kind=="repo" and .locators.id=="r1")' >/dev/null \ + || fail_msg "D2: enqueued entry must have observed_seq=1 and repo/r1 locator [$entry]" + # Poll again with NO further change -> no second enqueue (still exactly one). + "$DET" poll-once >/dev/null || fail_msg "D2: post-change no-op pass failed" + [ "$(depth)" = "1" ] || fail_msg "D2: no new change must NOT add a second enqueue, got depth $(depth)" + [ "$(det_seq)" = "1" ] || fail_msg "D2: observed_seq must stay 1 with no new delta, got $(det_seq)" +) && ok + +echo "== D3: revert A->B->A across polls is caught (delta detected) ==" +( + WAKE_STATE_HOME="$(fresh_state d3)" + export WAKE_STATE_HOME + unset WAKE_AGENT + stub="$TMP_ROOT/d3stub" + mkdir -p "$stub" + make_stub "$stub" + export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" + wl="$TMP_ROOT/d3.json" + write_watchlist "$wl" 1 + export WAKE_WATCH_LIST="$wl" + printf '## LANE-X\nx\n' >"$stub/board_file_b1" + # Use only the repo source for a clean A->B->A count. + printf 'STATE-A\n' >"$stub/repo_r1" + "$DET" poll-once >/dev/null || fail_msg "D3: baseline A failed" # baseline A + printf 'STATE-B\n' >"$stub/repo_r1" + "$DET" poll-once >/dev/null || fail_msg "D3: A->B failed" # delta 1 + printf 'STATE-A\n' >"$stub/repo_r1" + "$DET" poll-once >/dev/null || fail_msg "D3: B->A failed" # delta 2 (the revert) + # Two deltas total: A->B and the revert B->A. Neither is swallowed as "no change". + # (board_file b1 never changed, so it contributes 0.) + [ "$(det_seq)" = "2" ] || fail_msg "D3: revert A->B->A must produce TWO deltas (observed_seq=2), got $(det_seq)" +) && ok + +echo "== D4: single-instance flock (2nd instance refuses) ==" +( + WAKE_STATE_HOME="$(fresh_state d4)" + export WAKE_STATE_HOME + unset WAKE_AGENT + stub="$TMP_ROOT/d4stub" + mkdir -p "$stub" + make_stub "$stub" + export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" + wl="$TMP_ROOT/d4.json" + write_watchlist "$wl" 1 + export WAKE_WATCH_LIST="$wl" + printf 'SHA-AAA\n' >"$stub/repo_r1" + printf '## LANE-X\nx\n' >"$stub/board_file_b1" + lock="$WAKE_STATE_HOME/d4.lock" + export WAKE_DETECTOR_LOCK="$lock" + export WAKE_DETECTOR_INTERVAL=60 + # First long-lived instance: loops (interval 60), holds the flock. + "$DET" run >/dev/null 2>&1 & + runpid=$! + # Wait until it has acquired the lock (ready pid marker written post-flock). + for _ in $(seq 1 50); do + [ -f "$lock.pid" ] && break + sleep 0.1 + done + [ -f "$lock.pid" ] || fail_msg "D4: first instance never signalled lock acquisition" + # Second instance MUST refuse (non-zero) because the flock is held. + if "$DET" run --once >/dev/null 2>&1; then + fail_msg "D4: second instance must REFUSE while the flock is held (per-host single-instance)" + fi + kill "$runpid" 2>/dev/null || true + wait "$runpid" 2>/dev/null || true + # After the holder exits, a fresh instance may acquire the lock. The kernel's + # fd/flock release can lag process reaping slightly, so allow a bounded wait + # (the assertion is that the lock IS released eventually, not instantly). + reacquired=0 + for _ in $(seq 1 30); do + if "$DET" run --once >/dev/null 2>&1; then + reacquired=1 + break + fi + sleep 0.1 + done + [ "$reacquired" = "1" ] || fail_msg "D4: a new instance should acquire the lock once the holder is gone" +) && ok + +echo "== D5: watch-list schema_version out of manifest range -> FAIL LOUD ==" +( + WAKE_STATE_HOME="$(fresh_state d5)" + export WAKE_STATE_HOME + unset WAKE_AGENT + stub="$TMP_ROOT/d5stub" + mkdir -p "$stub" + make_stub "$stub" + export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" + printf 'SHA-AAA\n' >"$stub/repo_r1" + printf '## LANE-X\nx\n' >"$stub/board_file_b1" + # schema_version 999 is far outside the manifest's supported range. + wl="$TMP_ROOT/d5.json" + write_watchlist "$wl" 999 + export WAKE_WATCH_LIST="$wl" + err="$("$DET" poll-once 2>&1)" + rc=$? + [ "$rc" -ne 0 ] || fail_msg "D5: an out-of-range schema_version must FAIL LOUD (non-zero exit)" + echo "$err" | grep -qi 'schema_version' || fail_msg "D5: the failure must name schema_version [$err]" + echo "$err" | grep -qi 'range' || fail_msg "D5: the failure must state it is out of the supported range [$err]" + # And nothing was enqueued / no cursor advance under a rejected watch-list. + [ "$(depth)" = "0" ] || fail_msg "D5: a rejected watch-list must not enqueue, got depth $(depth)" + [ "$(det_seq)" = "0" ] || fail_msg "D5: a rejected watch-list must not advance observed_seq, got $(det_seq)" + # In-range still works (guards against a validator that rejects everything). + write_watchlist "$wl" 1 + "$DET" poll-once >/dev/null 2>&1 || fail_msg "D5: an in-range schema_version must be accepted" +) && ok + +echo "== D6: source error / ambiguous-empty -> FAIL LOUD, observed_seq NOT advanced (G2a) ==" +( + WAKE_STATE_HOME="$(fresh_state d6)" + export WAKE_STATE_HOME + unset WAKE_AGENT + stub="$TMP_ROOT/d6stub" + mkdir -p "$stub" + make_stub "$stub" + export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" + wl="$TMP_ROOT/d6.json" + write_watchlist "$wl" 1 + export WAKE_WATCH_LIST="$wl" + printf 'SHA-AAA\n' >"$stub/repo_r1" + printf '## LANE-X\nx\n' >"$stub/board_file_b1" + "$DET" poll-once >/dev/null || fail_msg "D6: baseline failed" # baseline + seq_before="$(det_seq)" + + # (i) A 403-style source error: adapter exits non-zero. MUST fail loud AND NOT + # be treated as "no change" AND NOT advance observed_seq. + printf '3\n' >"$stub/repo_r1.rc" # non-zero exit (privacy/403/partial class) + printf 'FORBIDDEN\n' >"$stub/repo_r1" + err="$("$DET" poll-once 2>&1)" + rc=$? + [ "$rc" -ne 0 ] || fail_msg "D6: a source error must FAIL LOUD (non-zero exit)" + echo "$err" | grep -qi 'FAIL LOUD' || fail_msg "D6: the source error must be loud [$err]" + [ "$(det_seq)" = "$seq_before" ] || fail_msg "D6: a source error must NOT advance observed_seq (got $(det_seq), was $seq_before)" + [ "$(depth)" = "0" ] || fail_msg "D6: a source error must NOT enqueue, got depth $(depth)" + + # (ii) Ambiguous-empty: adapter exits 0 but with EMPTY output. An empty that + # might mean "hidden" is never "no change" -> FAIL LOUD, no advance. + rm -f "$stub/repo_r1.rc" + : >"$stub/repo_r1" # empty, exit 0 + err="$("$DET" poll-once 2>&1)" + rc=$? + [ "$rc" -ne 0 ] || fail_msg "D6: an ambiguous-empty response must FAIL LOUD (non-zero exit)" + echo "$err" | grep -qi 'AMBIGUOUS-EMPTY' || fail_msg "D6: ambiguous-empty must be named in the loud failure [$err]" + [ "$(det_seq)" = "$seq_before" ] || fail_msg "D6: ambiguous-empty must NOT advance observed_seq (got $(det_seq))" + [ "$(depth)" = "0" ] || fail_msg "D6: ambiguous-empty must NOT enqueue, got depth $(depth)" + + # (iii) Recovery proof: once the source recovers with a REAL new value, the + # (un-swallowed) change is delivered — the error never masked it as "seen". + rm -f "$stub/repo_r1.rc" + printf 'SHA-RECOVERED\n' >"$stub/repo_r1" + "$DET" poll-once >/dev/null 2>&1 || true + [ "$(depth)" -ge 1 ] || fail_msg "D6: after recovery the real change must be delivered (not masked by the prior error)" + [ "$(det_seq)" -gt "$seq_before" ] || fail_msg "D6: observed_seq must advance only now, on the real post-recovery delta" +) && ok + +echo "== D7: anchor-scoped hashing (edit outside anchor = no-op; inside = delta) ==" +( + WAKE_STATE_HOME="$(fresh_state d7)" + export WAKE_STATE_HOME + unset WAKE_AGENT + stub="$TMP_ROOT/d7stub" + mkdir -p "$stub" + make_stub "$stub" + export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" + wl="$TMP_ROOT/d7.json" + write_watchlist "$wl" 1 + export WAKE_WATCH_LIST="$wl" + # Keep the repo source static so only the anchored board_file is exercised. + printf 'SHA-STATIC\n' >"$stub/repo_r1" + printf '## LANE-A\nalpha\n## LANE-X\ndecision: hold\n## LANE-Z\nzulu\n' >"$stub/board_file_b1" + "$DET" poll-once >/dev/null || fail_msg "D7: baseline failed" # baseline + d0="$(det_seq)" + # Edit OUTSIDE the "## LANE-X" anchor (LANE-A / LANE-Z): must be a NO-OP. + printf '## LANE-A\nALPHA-CHANGED\n## LANE-X\ndecision: hold\n## LANE-Z\nZULU-CHANGED\n' >"$stub/board_file_b1" + "$DET" poll-once >/dev/null || fail_msg "D7: out-of-anchor pass failed" + [ "$(det_seq)" = "$d0" ] || fail_msg "D7: an edit OUTSIDE the anchor must NOT wake (anchor-scoped), got seq $(det_seq)" + [ "$(depth)" = "0" ] || fail_msg "D7: out-of-anchor edit must NOT enqueue, got depth $(depth)" + # Edit INSIDE the "## LANE-X" anchor: must be a DELTA. + printf '## LANE-A\nALPHA-CHANGED\n## LANE-X\ndecision: GO\n## LANE-Z\nZULU-CHANGED\n' >"$stub/board_file_b1" + "$DET" poll-once >/dev/null || fail_msg "D7: in-anchor pass failed" + [ "$(det_seq)" -gt "$d0" ] || fail_msg "D7: an edit INSIDE the anchor MUST wake (human-decision file edit caught)" + [ "$(depth)" -ge 1 ] || fail_msg "D7: in-anchor edit must enqueue, got depth $(depth)" +) && ok + +echo +if [ -s "$FAILFILE" ]; then + echo "wake detector harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2 + exit 1 +fi +echo "wake detector harness: all invariants passed ($pass groups)" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 47a5e35e..714ff399 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-digest-hmac.sh" + "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-detector.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",