feat(wake): W4 — per-host delta-gated detector daemon (fail-loud source semantics, enqueues to W2 store) (#907)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: jason.woltje <jason@diversecanvas.com>
Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #907.
This commit is contained in:
2026-07-26 01:04:55 +00:00
committed by Mos
parent dd1391fd76
commit 5df47e735e
4 changed files with 794 additions and 4 deletions

View File

@@ -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: <cmd> <kind> <id>
# 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 <command> [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 <state>/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 <<EOF
$pairs
EOF
[ "$failed" -eq 0 ] || exit 1
}
# --- long-lived single-instance service ------------------------------------
cmd_run() {
local once=0
while [ $# -gt 0 ]; do
case "$1" in
--once)
once=1
shift
;;
*)
echo "detector.sh run: unknown option '$1'" >&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 "$@"

View File

@@ -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).

View File

@@ -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" <<EOF
{
"schema_version": $ver,
"repos": [{ "id": "r1", "remote": "example/repo", "class": "digest" }],
"board_files": [{ "id": "b1", "path": "BOARD.md", "anchor": "## LANE-X" }],
"watches": [
{ "lane": "lane-x", "sources": [
{ "kind": "repo", "id": "r1" },
{ "kind": "board_file", "id": "b1" }
] }
]
}
EOF
}
# A stub SOURCE ADAPTER whose per-source output is read from files under
# $STUB_DIR/<kind>_<id>, and whose exit code is read from
# $STUB_DIR/<kind>_<id>.rc (default 0). No network. The detector invokes it as
# `<cmd> <kind> <id>` with the source def on stdin (ignored here).
make_stub() {
local dir="$1"
cat >"$dir/adapter.sh" <<EOF
#!/usr/bin/env bash
set -u
kind="\$1"; id="\$2"
base="$dir/\${kind}_\${id}"
rc=0
[ -f "\$base.rc" ] && rc="\$(cat "\$base.rc")"
[ -f "\$base" ] && cat "\$base"
exit "\$rc"
EOF
chmod +x "$dir/adapter.sh"
}
echo "== D1: no-change poll -> 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)"

View File

@@ -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:*",