fix(wake): #908 unify observed_seq on a single store-side allocator (dissolve detector-private-counter seam) (#915)
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 #915.
This commit is contained in:
2026-07-26 06:02:02 +00:00
committed by Mos
parent 2378665eaf
commit e2ec927b1c
8 changed files with 487 additions and 157 deletions

View File

@@ -9,11 +9,14 @@
# 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.
# §1.2 observe -> enqueue into the W2 durable store (the store ALLOCATES the
# observed_seq — single store-side allocator, #908).
# §2.4 Cursor semantics: `observed_seq` is a monotonic int, AUTHORITATIVE, now
# allocated SOLELY by store.sh enqueue (#908 unified the two former
# allocators). The detector no longer keeps a private counter: it enqueues
# and captures the store-assigned seq. 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
@@ -100,8 +103,9 @@ Commands:
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.
cursors Print the store cursors (observed_seq is
the store's single source of truth, #908;
the detector keeps no private counter).
Environment:
WAKE_WATCH_LIST Path to the operator watch-list JSON (required).
@@ -171,19 +175,15 @@ _load_watchlist() {
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"
}
# --- observed_seq: the store is the SOLE allocator (#908) -------------------
#
# The detector NO LONGER keeps a private observed_seq counter. That private
# counter was the shared root of three defects (burn-before-enqueue, W5
# dual-allocator aliasing, and the migration-restart silent-swallow); see the PR
# for #908. observed_seq is now allocated EXCLUSIVELY by store.sh enqueue (which
# reads its own cursor and returns the assigned seq). The detector calls enqueue
# WITHOUT --seq and captures the store-returned seq for logging/locators — it
# never allocates a seq itself, so the seam is gone.
# --- per-watch last-observed hash (§2.4 revert/ABA-at-rest detection) --------
@@ -274,10 +274,11 @@ _poll_source() {
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)"
# --- DELTA: enqueue and let the STORE allocate observed_seq (#908) ---------
# (a revert A->B->A lands here because $h != the stored $last). The store is
# the sole allocator: we call enqueue WITHOUT --seq and capture the seq it
# assigns (for logging/locators/emit). We do NOT allocate a seq ourselves.
local locators emit_ts
emit_ts="$(date +%s)"
# NB: `def` is a reserved word in jq — the source-definition arg is `$sdef`.
locators="$(jq -cn \
@@ -288,16 +289,19 @@ _poll_source() {
'{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")
local args=(enqueue --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
local seq
if ! seq="$("$STORE_SH" "${args[@]}")"; then
echo "detector.sh: store enqueue FAILED for '$kind/$id' (observed_seq NOT advanced; hash NOT advanced)" >&2
return 1
fi
# Only AFTER a durable enqueue do we advance the per-watch last-observed hash.
# (store-allocated seq $seq is available for logging/emit if needed.)
: "$seq"
printf '%s' "$h" | _atomic_write "$hf"
return 0
}
@@ -420,9 +424,9 @@ cmd_run() {
}
cmd_cursors() {
local counter
counter="$(_wake_read_int "$DET_DIR/observed_seq_counter" 0)"
printf 'detector_observed_seq=%s\n' "$counter"
# observed_seq has a SINGLE source of truth now (#908): the STORE cursor. The
# detector keeps no private counter, so cursors simply reports the store's
# authoritative cursors (observed_seq / consumed_seq / pending_depth).
"$STORE_SH" cursors 2>/dev/null || true
}