Files
stack/packages/mosaic/framework/tools/wake/store.sh
mosaic-coder eb37eae1f8
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
fix(wake): #917 gate the final observed_seq cursor write + observed.set/cursor consistency (defense-in-depth)
Defense-in-depth hardening of store.sh cmd_enqueue surfaced by the #915 review
(obs#2): the final observed_seq cursor _atomic_write was the ONE durable write not
wrapped in a failure check, and was cross-file non-atomic with the observed.set
write immediately before it. Practically unreachable today, but a cursor-write
failure after a successful observed.set write could (a) return spurious success
while the allocation stayed uncommitted, and (b) leave observed.set advanced while
the cursor lagged (cross-file inconsistent).

Fix (additive; preserves #908 exactly):
- GATE the final cursor write like the pending/observed.set writes (#908): a
  cursor-write failure is now FAIL-LOUD (non-zero + diagnostic), never swallowed.
- On cursor-write failure, ROLL observed.set BACK to its pre-write snapshot, so
  observed.set and the cursor either BOTH advance or NEITHER does — never a
  stranded observed.set entry above the cursor.

#908 invariants intact: single store-side allocator, atomic allocate+enqueue under
flock, arrow-1 no-burn (pending write still FIRST, still aborts before any cursor
advance), anti-swallow <=consumed fail-loud, W2 contiguous-prefix CONSUMED. The
cursor stays the sole COMMIT point, so a pending-ahead state is exactly the one
#908 already tolerates on its observed.set-failure path. On-disk format UNCHANGED
(read-compatible).

Test: new T11 in test-wake-store-ack.sh forces the final cursor _atomic_write to
fail (bind-mount EBUSY over observed_seq inside an unshare mount+user namespace,
seeded so the cursor READ still succeeds) after pending+observed.set succeed, and
asserts fail-loud + observed.set/cursor consistency + intact consumed prefix. RED
against pre-#917 code; all store-ack/race/reconcile/detector groups stay GREEN.
manifest bumped 0.6.8 -> 0.6.9.

Closes #917
Part of #892

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158NZqN2n2ymKFeJAZ4GUCb
2026-07-26 09:18:59 -05:00

426 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
# store.sh — A2 of the wake canon (EPIC #892, W2): the THREE-CURSOR durable
# queue (store + drain lib).
#
# CONTRACT ANCHORS (docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md):
# §1.2 three-cursor data-flow: observe -> durable store -> coalesce -> drain.
# §2.3 classes & durability: ALL classes durable; coalescing is optional.
# §2.4 cursor semantics: observed_seq is authoritative; SHAs are descriptors.
#
# Three cursors:
# observed_seq (store-owned SINGLE ALLOCATOR, #908) — monotonic int assigned by
# THIS lib at enqueue: next = observed_seq + 1, under an exclusive
# lock, committed IFF the durable write succeeds. Authoritative.
# There is exactly ONE allocator (this store); the detector and
# reconciler no longer allocate independently. Source SHAs are
# descriptors, NOT the cursor (§2.4).
# pending-inbox (durable) — append store; retains until CONSUMED; survives
# pane death / park / restart.
# consumed_seq (consumer-owned) — advances ONLY on a CONSUMED ack, over a
# CONTIGUOUS gapless prefix <=N. NEVER on sender exit-0 / paste.
#
# Coalesce (§1.2/§2.3): class=digest REPLACES the pending cumulative-state entry
# (newest subsumes prior). Non-coalescible classes (actionable, human, ...)
# APPEND. ALL classes are stored durably — durability is NEVER bypassed.
# Absent class => treated as `actionable` (fail-safe).
#
# HMAC: entries carry an `hmac` field left as an UNSIGNED PLACEHOLDER ("").
# Signing is W3/A5, explicitly NOT W2.
#
# Every state mutation is atomic write-tmp+rename (see _wake-common.sh).
#
# Operator-agnostic: state via XDG/env only; no operator paths/names/secrets.
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)"
_need_jq() {
command -v jq >/dev/null 2>&1 || {
echo "store.sh: jq is required" >&2
exit 3
}
}
usage() {
cat >&2 <<'EOF'
Usage: store.sh <command> [options]
Commands:
init Create/repair the XDG state layout.
enqueue [--class C] [opts] Record an observation into the durable
pending-inbox (coalesce or append) and
ALLOCATE its observed_seq. store.sh is the
SOLE allocator of observed_seq (#908):
under an exclusive enqueue lock it reads
the observed_seq cursor, sets
next = observed_seq + 1, writes the
pending record + observed.set + the cursor
as ONE transaction, and PRINTS next to
stdout. The cursor advances IFF the durable
write succeeds (no burn-before-enqueue).
--seq N LEGACY explicit observed_seq (optional). Normal callers
(detector, reconciler) OMIT this and let the store
allocate. Retained only for tests/tools that must place a
SPECIFIC seq (e.g. construct a gap). An explicit seq that
is <= consumed_seq is REFUSED loudly (anti-swallow): the
idempotent-ignore path can never silently drop a wake.
--class C digest|actionable|human|terminal-log|reaction.
Absent/empty => actionable (fail-safe).
--locators JSON JSON value for the entry's locators (default {}).
--emit-ts TS epoch seconds (default: now).
drain [--require-idle-cmd CMD] Emit the deliverable set (pending-inbox,
coalesced) as JSONL. WHAT to deliver;
the actual paste is out of scope.
If --require-idle-cmd is given and it
exits non-zero, emit nothing (not idle).
consume --upto N Advance consumed_seq over the contiguous
gapless prefix <=N; drop consumed
entries. Rejects a gap (cannot ack N
while N-1 is unconsumed). Cumulative &
idempotent.
cursors Print observed_seq / consumed_seq / depth.
Environment:
WAKE_STATE_HOME override base (default ${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/wake)
WAKE_AGENT per-agent queue namespace (default: default)
EOF
}
cmd_init() {
_wake_init_dir "$STATE_DIR" || {
echo "store.sh: failed to init $STATE_DIR" >&2
exit 1
}
# Maintenance/daemon-start stale-tmp reap (#927). Cleanup is OFF the per-enqueue
# hot path; `init` is a natural once-per-start maintenance point. It is
# age-scoped, so even if an operator runs `init` while an enqueue is in flight
# it can only reap demonstrably-orphaned (crash-left) tmps, never a live write.
_wake_clean_stale_tmp "$STATE_DIR"
echo "$STATE_DIR"
}
cmd_enqueue() {
_need_jq
local seq='' class='' locators='{}' emit_ts=''
while [ $# -gt 0 ]; do
case "$1" in
--seq)
seq="${2:-}"
shift 2
;;
--class)
class="${2:-}"
shift 2
;;
--locators)
locators="${2:-}"
shift 2
;;
--emit-ts)
emit_ts="${2:-}"
shift 2
;;
*)
echo "store.sh enqueue: unknown option '$1'" >&2
exit 2
;;
esac
done
# --seq is OPTIONAL (#908): normal callers omit it and the store allocates.
# If supplied (legacy/explicit), it must be a non-negative integer.
local explicit=0
if [ -n "$seq" ]; then
explicit=1
case "$seq" in
*[!0-9]*)
echo "store.sh enqueue: --seq must be a non-negative integer" >&2
exit 2
;;
esac
fi
# Absent class => actionable (fail-safe, §2.3).
[ -n "$class" ] || class="actionable"
case "$class" in
digest | actionable | human | terminal-log | reaction) : ;;
*)
echo "store.sh enqueue: unknown class '$class'" >&2
exit 2
;;
esac
[ -n "$emit_ts" ] || emit_ts="$(date +%s)"
case "$emit_ts" in
*[!0-9]*)
echo "store.sh enqueue: --emit-ts must be epoch seconds" >&2
exit 2
;;
esac
# Validate locators is well-formed JSON.
if ! printf '%s' "$locators" | jq -e . >/dev/null 2>&1; then
echo "store.sh enqueue: --locators must be valid JSON" >&2
exit 2
fi
# Ensure the layout exists. NB (#927): _wake_init_dir NO LONGER reaps stale
# tmps — that would race a concurrent enqueue's live in-flight write (deleting
# its tmp mid-rename -> spurious "durable pending write FAILED"). Stale-tmp
# reaping is a maintenance action (store.sh init / detector tick), never on this
# hot path.
_wake_init_dir "$STATE_DIR"
# --- serialize the whole allocate+write transaction (#908) ----------------
# A single store-side allocator means the read of observed_seq, the durable
# write, and the cursor bump MUST be one critical section: two concurrent
# enqueues that both read observed_seq=N would otherwise both allocate N+1 and
# collide. The lock (flock where present) makes concurrent enqueues get
# DISTINCT seqs.
_wake_lock_acquire "$STATE_DIR/.enqueue.lock" || {
echo "store.sh enqueue: could not acquire the enqueue lock ($STATE_DIR/.enqueue.lock)" >&2
exit 1
}
local consumed observed
consumed="$(_wake_read_int "$STATE_DIR/consumed_seq" 0)"
observed="$(_wake_read_int "$STATE_DIR/observed_seq" 0)"
if [ "$explicit" -eq 0 ]; then
# ALLOCATE: the store is the sole allocator. next = observed_seq + 1.
seq=$((observed + 1))
fi
# --- anti-swallow FAIL-LOUD (#908, arrow #3) ------------------------------
# observed_seq >= consumed_seq always (consumed advances only over the gapless
# observed prefix), so an ALLOCATED next = observed+1 is ALWAYS > consumed. The
# old "seq <= consumed => idempotent no-op" path is therefore UNREACHABLE for an
# allocation; if it is ever reached the store is inconsistent (corruption) — we
# FAIL LOUD, never a silent no-op. For a LEGACY explicit --seq, a seq inside the
# consumed prefix is likewise refused loudly so it can never silently swallow a
# fresh obligation (the migration-restart killer must be structurally impossible).
if [ "$seq" -le "$consumed" ]; then
_wake_lock_release
if [ "$explicit" -eq 1 ]; then
echo "store.sh enqueue: REFUSED explicit --seq $seq <= consumed_seq $consumed — anti-swallow (#908): a seq inside the consumed prefix is never a fresh obligation and must NEVER be silently ignored." >&2
else
echo "store.sh enqueue: FAIL LOUD (#908 allocation invariant) — computed observed_seq $seq <= consumed_seq $consumed (observed_seq cursor=$observed). The store is inconsistent; refusing to allocate rather than silently no-op." >&2
fi
exit 1
fi
# Build the durable entry. `hmac` is an UNSIGNED PLACEHOLDER (W3/A5 signs it).
local entry
entry="$(jq -cn \
--argjson seq "$seq" \
--arg class "$class" \
--argjson locators "$locators" \
--argjson emit_ts "$emit_ts" \
'{observed_seq:$seq, locators:$locators, class:$class, emit_ts:$emit_ts, hmac:""}')" || {
_wake_lock_release
echo "store.sh enqueue: failed to build entry" >&2
exit 1
}
# --- durable store write (coalesce vs append) -----------------------------
# digest: REPLACE the single pending cumulative-state entry (drop any existing
# digest lines, then append the newest). Newest subsumes prior.
# others: APPEND (never replaced).
# In BOTH cases the entry lands in the durable store.
#
# ORDERING (#908, arrow #1 — no burn-before-enqueue): the durable pending write
# comes FIRST and its failure ABORTS before the observed.set / observed_seq
# cursor writes. The cursor advances IFF the durable pending write succeeds, so
# a failed enqueue can never burn a seq that never reached the store.
local new_pending
if [ "$class" = "digest" ]; then
new_pending="$(jq -c 'select(.class != "digest")' "$STATE_DIR/pending.jsonl" 2>/dev/null || true)"
new_pending="$(printf '%s\n%s\n' "$new_pending" "$entry" | grep -v '^[[:space:]]*$' || true)"
else
new_pending="$(cat "$STATE_DIR/pending.jsonl" 2>/dev/null; printf '%s\n' "$entry")"
new_pending="$(printf '%s' "$new_pending" | grep -v '^[[:space:]]*$' || true)"
fi
if ! printf '%s\n' "$new_pending" | grep -v '^[[:space:]]*$' | _atomic_write "$STATE_DIR/pending.jsonl"; then
_wake_lock_release
echo "store.sh enqueue: durable pending write FAILED for seq $seq — observed_seq cursor NOT advanced (no burned seq, no interior gap)." >&2
exit 1
fi
# --- observed.set: record the seq in the live window (gap-detector SoT) ----
# Snapshot observed.set's prior content FIRST: if the final cursor write below
# fails AFTER this write advances observed.set, we roll observed.set back to this
# snapshot so observed.set can never be left cross-file inconsistent with the
# cursor (#917 — observed.set ahead of a cursor that never committed).
local prev_observed_set
prev_observed_set="$(cat "$STATE_DIR/observed.set" 2>/dev/null || true)"
if ! {
printf '%s' "$prev_observed_set"
printf '\n%s\n' "$seq"
} | grep -v '^[[:space:]]*$' | sort -n | uniq | _atomic_write "$STATE_DIR/observed.set"; then
_wake_lock_release
echo "store.sh enqueue: observed.set write FAILED for seq $seq — observed_seq cursor NOT advanced." >&2
exit 1
fi
# --- observed_seq cursor: max monotonic int (COMMIT of the allocation) -----
# Written LAST: only now, after the durable pending + observed.set writes
# succeeded, does the cursor advance. This is the atomic commit point of the
# allocation.
#
# #917 (defense-in-depth): this final write is GATED like the pending/observed.set
# writes (#908) — its failure is FAIL-LOUD, never silently swallowed (which would
# return success while the allocation stayed uncommitted). And because observed.set
# was already advanced just above, a cursor-write failure would leave observed.set
# ahead of the cursor (cross-file inconsistent); so on failure we ROLL observed.set
# BACK to its pre-write snapshot before failing loud. Net: observed.set and the
# cursor either BOTH advance or NEITHER does — never a stranded observed.set entry
# above the cursor. (The pending write may remain ahead, exactly as #908 already
# tolerates on the observed.set-failure path: the cursor is the sole COMMIT point,
# so an uncommitted pending entry is re-derived/reconciled, never consumed — the
# allocation is simply not committed.) On-disk format is UNCHANGED (read-compatible).
if [ "$seq" -gt "$observed" ]; then
if ! printf '%s' "$seq" | _atomic_write "$STATE_DIR/observed_seq"; then
printf '%s' "$prev_observed_set" | grep -v '^[[:space:]]*$' | _atomic_write "$STATE_DIR/observed.set" || true
_wake_lock_release
echo "store.sh enqueue: observed_seq cursor write FAILED for seq $seq — allocation NOT committed; rolled observed.set back so it stays consistent with the cursor (#917)." >&2
exit 1
fi
fi
_wake_lock_release
# Print the allocated observed_seq to stdout (sole-allocator contract): the
# detector/reconciler capture it for logging/locators/emit.
printf '%s\n' "$seq"
}
cmd_drain() {
local idle_cmd=''
while [ $# -gt 0 ]; do
case "$1" in
--require-idle-cmd)
idle_cmd="${2:-}"
shift 2
;;
*)
echo "store.sh drain: unknown option '$1'" >&2
exit 2
;;
esac
done
# Drain returns the deliverable set ONLY when the pane is idle-at-prompt. The
# actual idle detection + paste is out of W2 scope (send-message.sh); the
# caller supplies an idle predicate. No predicate => emit unconditionally.
if [ -n "$idle_cmd" ]; then
if ! sh -c "$idle_cmd" >/dev/null 2>&1; then
return 0 # not idle-at-prompt: deliver nothing this cycle.
fi
fi
[ -f "$STATE_DIR/pending.jsonl" ] || return 0
grep -v '^[[:space:]]*$' "$STATE_DIR/pending.jsonl" 2>/dev/null || true
}
cmd_consume() {
local upto=''
while [ $# -gt 0 ]; do
case "$1" in
--upto)
upto="${2:-}"
shift 2
;;
*)
echo "store.sh consume: unknown option '$1'" >&2
exit 2
;;
esac
done
case "$upto" in
'' | *[!0-9]*)
echo "store.sh consume: --upto must be a non-negative integer" >&2
exit 2
;;
esac
_need_jq
_wake_init_dir "$STATE_DIR"
local consumed observed
consumed="$(_wake_read_int "$STATE_DIR/consumed_seq" 0)"
observed="$(_wake_read_int "$STATE_DIR/observed_seq" 0)"
# Cumulative & idempotent: CONSUMED N implies all <=N. N already covered is a
# no-op success (the sender checks cursor-advanced-past-X, not per-wake).
if [ "$upto" -le "$consumed" ]; then
echo "$consumed"
return 0
fi
# Never consume beyond what was observed.
if [ "$upto" -gt "$observed" ]; then
echo "store.sh consume: cannot CONSUMED $upto beyond observed_seq $observed" >&2
exit 1
fi
# CONTIGUOUS gapless prefix: every seq in (consumed, upto] must have been
# observed. A missing interior seq is a GAP => reject (cannot ack N while N-1
# is unconsumed). observed.set is coalescing-immune, so a subsumed digest seq
# still counts as observed and does not read as a gap.
local k
k=$((consumed + 1))
while [ "$k" -le "$upto" ]; do
if ! grep -qxF "$k" "$STATE_DIR/observed.set" 2>/dev/null; then
echo "store.sh consume: gap at seq $k — cannot CONSUMED $upto while $k is unobserved/unconsumed" >&2
exit 1
fi
k=$((k + 1))
done
# Advance the consumer cursor and drop the now-consumed prefix from the
# durable store (retain-until-CONSUMED is satisfied).
jq -c "select(.observed_seq > $upto)" "$STATE_DIR/pending.jsonl" 2>/dev/null |
grep -v '^[[:space:]]*$' | _atomic_write "$STATE_DIR/pending.jsonl" || true
awk -v c="$upto" 'NF && $1+0 > c' "$STATE_DIR/observed.set" 2>/dev/null |
_atomic_write "$STATE_DIR/observed.set" || true
printf '%s' "$upto" | _atomic_write "$STATE_DIR/consumed_seq"
echo "$upto"
}
cmd_cursors() {
_wake_init_dir "$STATE_DIR"
local observed consumed depth
observed="$(_wake_read_int "$STATE_DIR/observed_seq" 0)"
consumed="$(_wake_read_int "$STATE_DIR/consumed_seq" 0)"
depth="$(grep -cv '^[[:space:]]*$' "$STATE_DIR/pending.jsonl" 2>/dev/null || echo 0)"
printf 'observed_seq=%s\nconsumed_seq=%s\npending_depth=%s\n' "$observed" "$consumed" "$depth"
}
main() {
[ $# -ge 1 ] || {
usage
exit 2
}
local cmd="$1"
shift
case "$cmd" in
init) cmd_init "$@" ;;
enqueue) cmd_enqueue "$@" ;;
drain) cmd_drain "$@" ;;
consume) cmd_consume "$@" ;;
cursors) cmd_cursors "$@" ;;
-h | --help | help) usage ;;
*)
echo "store.sh: unknown command '$cmd'" >&2
usage
exit 2
;;
esac
}
main "$@"