fix(wake): #925 framework-ship canon fallback-wake (systemd timer + schema bound + A10 install/validate) — F7 out of the box
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Framework-ship the canon-side FALLBACK WAKE (F7 replacement-before-retirement) so hosts get it out of the box instead of hand-wiring it per host. ADDITIVE; honors #869 / Gate A/B discipline. 1. New framework units systemd/user/mosaic-wake-fallback.{timer,service}: a low-frequency SAFETY drain (oneshot service running the canon drain `digest.sh render --from-store`) fired by a per-class cadence timer, INDEPENDENT of the event-driven detector, so a stalled detector/daemon can never silently starve delivery. Owned via the existing `systemd/**` glob in framework-manifest.txt (Gate A) — no new owned path, no second authority. 2. Optional additive per-class `fallback_cadence` bound in wake-watch-list.schema.json (config, not code). Backward-compatible within schema_version 1, so [schema_min, schema_max] stays [1,1] and the detector's Gate B range check is unchanged. 3. A10 wake-install.sh enumerates + links + validates the two units into the user systemd search path (idempotent, fail-closed); write-fallback-cadence writes the per-class cadence as a blank-reset drop-in (exactly one effective OnUnitActiveUSec). 4. F7 install-validate: the §5 legacy reap now REFUSES unless the canon fallback wake is proven live (installed + schedulable floor always; enabled + proven-firing when a live user manager is probeable, mirroring #913). Red-first: test-wake-install.sh I11 (F7 reap-refuses-without-live-fallback), I12 (stalled detector still drains), I13 (install links+validates units), I14 (per-class cadence blank-reset = exactly one); test-wake-detector.sh D9 (optional fallback_cadence in-range accepted; out-of-range still fails loud). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158NZqN2n2ymKFeJAZ4GUCb
This commit is contained in:
@@ -193,13 +193,19 @@ echo "== I6: reset->verify->retire — overlap keeps legacy running; only §4-ve
|
||||
export WAKE_SNAPSHOT_DIR="$SNAP"
|
||||
TIMER="mosaic-heartbeat@c.timer"
|
||||
printf '[Timer]\nOnUnitActiveSec=15min\n' >"$UD/$TIMER"
|
||||
# F7 (#925): the vector-passed reap now REFUSES unless the canon FALLBACK WAKE is
|
||||
# proven live (replacement-before-retirement). Provision it at the schedulable
|
||||
# floor so the lifecycle path under test still reaches the reap. (I11 is the
|
||||
# dedicated F7 refuse/allow control.)
|
||||
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.timer" "$UD/mosaic-wake-fallback.timer"
|
||||
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.service" "$UD/mosaic-wake-fallback.service"
|
||||
# (a) overlap phase (NO --vector-passed): reset+verify, legacy LEFT RUNNING.
|
||||
out="$(bash "$WI" reset-verify-retire c --interval 30min 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "I6: overlap reset-verify must succeed (rc=$rc) [$out]"
|
||||
[ -f "$UD/$TIMER" ] || fail_msg "I6: overlap phase must LEAVE the legacy timer running (retire withheld)"
|
||||
[ -f "$SNAP/$TIMER" ] || fail_msg "I6: the legacy timer must be snapshotted before any retire"
|
||||
echo "$out" | grep -qi 'LEFT RUNNING' || fail_msg "I6: overlap must announce retire is withheld [$out]"
|
||||
# (b) §4-vector pass: retire LAST, snapshot-guarded.
|
||||
# (b) §4-vector pass: retire LAST, snapshot-guarded (fallback proven live above).
|
||||
out2="$(bash "$WI" reset-verify-retire c --interval 30min --vector-passed 2>&1)"; rc2=$?
|
||||
[ "$rc2" -eq 0 ] || fail_msg "I6: vector-passed retire must succeed (rc=$rc2) [$out2]"
|
||||
[ ! -f "$UD/$TIMER" ] || fail_msg "I6: on §4-vector pass the legacy timer must be RETIRED"
|
||||
@@ -315,6 +321,107 @@ echo "== I10: systemd search-path — install links mosaic-wake.service into the
|
||||
bash "$WI" validate-systemd-path >/dev/null 2>&1 || fail_msg "I10: re-install must keep the unit resolvable"
|
||||
) && ok
|
||||
|
||||
# ── (#925) canon FALLBACK WAKE (F7 replacement-before-retirement) ──────────────
|
||||
|
||||
echo "== I11: F7 — the legacy reap REFUSES unless the canon fallback wake is proven live =="
|
||||
(
|
||||
UD="$(fresh i11-units)"; SNAP="$(fresh i11-snap)"
|
||||
export WAKE_SYSTEMD_USER_DIR="$UD"
|
||||
export WAKE_SNAPSHOT_DIR="$SNAP"
|
||||
TIMER="mosaic-heartbeat@f.timer"
|
||||
printf '[Timer]\nOnUnitActiveSec=15min\n' >"$UD/$TIMER"
|
||||
# (a) fallback-proven-live is the F7 gate primitive: with NO fallback installed,
|
||||
# it must REFUSE (schedulable floor not met).
|
||||
out0="$(bash "$WI" fallback-proven-live 2>&1)"; rc0=$?
|
||||
[ "$rc0" -ne 0 ] || fail_msg "I11: fallback-proven-live must FAIL when the fallback wake is not installed (rc=$rc0)"
|
||||
echo "$out0" | grep -qi 'F7' || fail_msg "I11: the refusal must name the F7 precondition [$out0]"
|
||||
# (b) NEGATIVE — vector-passed reap with NO live fallback must be REFUSED and the
|
||||
# legacy timer LEFT RUNNING (never a coverage gap).
|
||||
out="$(bash "$WI" reset-verify-retire f --interval 30min --vector-passed 2>&1)"; rc=$?
|
||||
[ "$rc" -ne 0 ] || fail_msg "I11: reap must be REFUSED without a proven-live fallback (rc=$rc)"
|
||||
echo "$out" | grep -qi 'FALLBACK WAKE is not proven live' || fail_msg "I11: refusal must name the un-proven fallback [$out]"
|
||||
[ -f "$UD/$TIMER" ] || fail_msg "I11: a refused reap must LEAVE the legacy timer running (F7 — no coverage gap)"
|
||||
# (c) POSITIVE — provision the fallback at the schedulable floor, then the reap
|
||||
# proceeds (F7 satisfied) and the legacy timer is retired.
|
||||
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.timer" "$UD/mosaic-wake-fallback.timer"
|
||||
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.service" "$UD/mosaic-wake-fallback.service"
|
||||
bash "$WI" fallback-proven-live >/dev/null 2>&1 || fail_msg "I11: fallback-proven-live must PASS once the fallback units are schedulable"
|
||||
out2="$(bash "$WI" reset-verify-retire f --interval 30min --vector-passed 2>&1)"; rc2=$?
|
||||
[ "$rc2" -eq 0 ] || fail_msg "I11: with a proven-live fallback the reap must succeed (rc=$rc2) [$out2]"
|
||||
[ ! -f "$UD/$TIMER" ] || fail_msg "I11: with F7 satisfied the legacy timer must be RETIRED"
|
||||
) && ok
|
||||
|
||||
echo "== I12: fallback drain — a STALLED detector still gets delivery via the canon fallback drain =="
|
||||
(
|
||||
H="$(fresh i12-state)"
|
||||
export WAKE_STATE_HOME="$H"
|
||||
unset WAKE_AGENT
|
||||
STORE="$SCRIPT_DIR/store.sh"; DIGEST="$SCRIPT_DIR/digest.sh"
|
||||
# A pending obligation lands in the durable inbox — but the detector daemon is
|
||||
# STALLED/absent (we never run detector.sh). Enqueue directly to the store.
|
||||
seq="$(bash "$STORE" enqueue --class digest \
|
||||
--locators '{"kind":"repo","id":"STALLED-DRAIN-MARK","path":"BOARD.md","observed_hash":"deadbeef"}' 2>/dev/null)"
|
||||
[ -n "$seq" ] || fail_msg "I12: store enqueue must allocate a seq"
|
||||
# The canon fallback drain is EXACTLY the shipped unit's ExecStart: digest render
|
||||
# --from-store. Firing it (as the timer would) must surface the pending obligation
|
||||
# even with no detector running — no starvation of the drain.
|
||||
out="$(bash "$DIGEST" render --from-store 2>/dev/null)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "I12: the canon fallback drain must exit 0 (rc=$rc)"
|
||||
echo "$out" | grep -q 'STALLED-DRAIN-MARK' \
|
||||
|| fail_msg "I12: the fallback drain must DELIVER the pending obligation a stalled detector left (no delivery starvation) [$out]"
|
||||
# Bind the invariant to the SHIPPED unit: its ExecStart must be this canon drain.
|
||||
UNIT="$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.service"
|
||||
grep -qE '^ExecStart=.*digest\.sh render --from-store' "$UNIT" \
|
||||
|| fail_msg "I12: mosaic-wake-fallback.service ExecStart must fire the canon drain (digest.sh render --from-store)"
|
||||
) && ok
|
||||
|
||||
echo "== I13: install links + validates the canon fallback timer+service into the search path (idempotent) =="
|
||||
(
|
||||
TGT="$(fresh i13-target)"
|
||||
UD="$(fresh i13-systemd)"
|
||||
export WAKE_INSTALL_SOURCE="$FRAMEWORK_ROOT"
|
||||
export WAKE_INSTALL_TARGET="$TGT"
|
||||
export WAKE_SYSTEMD_USER_DIR="$UD"
|
||||
bash "$WI" install >/dev/null 2>&1 || fail_msg "I13: install must succeed"
|
||||
# (a) both fallback units resolve in the user systemd search path.
|
||||
[ -e "$UD/mosaic-wake-fallback.timer" ] || fail_msg "I13: fallback timer must be linked into the search path"
|
||||
[ -e "$UD/mosaic-wake-fallback.service" ] || fail_msg "I13: fallback service must be linked into the search path"
|
||||
bash "$WI" validate-fallback-units >/dev/null 2>&1 || fail_msg "I13: validate-fallback-units must pass after install"
|
||||
# (b) NEGATIVE CONTROL: a missing fallback unit is CAUGHT (fail loud, names the miss).
|
||||
rm -f "$UD/mosaic-wake-fallback.timer"
|
||||
out="$(bash "$WI" validate-fallback-units 2>&1)"; rc=$?
|
||||
[ "$rc" -ne 0 ] || fail_msg "I13: validate must FAIL when a fallback unit is not in the search path (rc=$rc) [$out]"
|
||||
echo "$out" | grep -qi 'search path' || fail_msg "I13: validate failure must name the search-path miss [$out]"
|
||||
# (c) idempotent re-install: exactly one entry per unit, still resolvable.
|
||||
bash "$WI" install >/dev/null 2>&1 || fail_msg "I13: re-run install must succeed"
|
||||
nt="$(find "$UD" -maxdepth 1 -name 'mosaic-wake-fallback.timer' | grep -c .)"
|
||||
ns="$(find "$UD" -maxdepth 1 -name 'mosaic-wake-fallback.service' | grep -c .)"
|
||||
[ "$nt" -eq 1 ] && [ "$ns" -eq 1 ] || fail_msg "I13: re-install must not duplicate the fallback entries (timer=$nt service=$ns)"
|
||||
bash "$WI" validate-fallback-units >/dev/null 2>&1 || fail_msg "I13: re-install must keep the fallback units resolvable"
|
||||
) && ok
|
||||
|
||||
echo "== I14: per-class fallback cadence — blank-reset yields EXACTLY ONE OnUnitActiveUSec (negative control leaks two) =="
|
||||
(
|
||||
UD="$(fresh i14-units)"
|
||||
export WAKE_SYSTEMD_USER_DIR="$UD"
|
||||
TIMER="mosaic-wake-fallback.timer"
|
||||
# The shipped timer carries the base OnUnitActiveSec placeholder (=1h).
|
||||
cp "$FRAMEWORK_ROOT/systemd/user/$TIMER" "$UD/$TIMER"
|
||||
grep -q '^OnUnitActiveSec=1h' "$UD/$TIMER" || fail_msg "I14: shipped fallback timer must carry a base OnUnitActiveSec placeholder"
|
||||
# write-fallback-cadence writes the per-class cadence in BLANK-RESET form.
|
||||
out="$(bash "$WI" write-fallback-cadence 30min 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "I14: write-fallback-cadence must succeed + verify single (rc=$rc) [$out]"
|
||||
echo "$out" | grep -qi 'EXACTLY ONE\|exactly one' || fail_msg "I14: the cadence write must confirm exactly-one OnUnitActiveUSec [$out]"
|
||||
[ -f "$UD/$TIMER.d/cadence.conf" ] || fail_msg "I14: the per-class cadence drop-in must be written under <timer>.d/"
|
||||
grep -qz $'OnUnitActiveSec=\nOnUnitActiveSec=30min' "$UD/$TIMER.d/cadence.conf" \
|
||||
|| fail_msg "I14: the drop-in must use the blank-reset form (empty reset line, then the value)"
|
||||
bash "$WI" verify-single "$TIMER" >/dev/null 2>&1 || fail_msg "I14: base(1h)+blank-reset(30min) must resolve exactly one OnUnitActiveUSec"
|
||||
# NEGATIVE CONTROL: a drop-in WITHOUT the reset line appends -> base 1h + 30min -> two -> fail.
|
||||
printf '[Timer]\nOnUnitActiveSec=30min\n' >"$UD/$TIMER.d/cadence.conf"
|
||||
out2="$(bash "$WI" verify-single "$TIMER" 2>&1)"; rc2=$?
|
||||
[ "$rc2" -ne 0 ] || fail_msg "I14: without the reset line two cadences must leak (verify should FAIL) [$out2]"
|
||||
) && ok
|
||||
|
||||
echo
|
||||
if [ -s "$FAILFILE" ]; then
|
||||
echo "wake install harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
||||
|
||||
Reference in New Issue
Block a user