fix(wake): reconciler fails closed on the observed_seq dual-allocator hazard (#908)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Review of #909 found a blocking correctness defect: the reconciler's "serialised => safe" claim was FACTUALLY WRONG. observed_seq has two INDEPENDENT allocators — the detector's private counter ($STATE_DIR/detector/ observed_seq_counter, W4) and the reconciler's store-cursor+offset — so even STRICTLY SEQUENTIAL calls collide: reconciler enumerates alpha->seq1, beta->seq2 (store observed_seq=2, detector private still 0); the detector's next delta allocates private 0->1 and enqueues seq1, ALIASING alpha. A consumer acking CONSUMED 1 then silently DROPS a distinct obligation — the exact G3 swallow the reconciler exists to prevent, via seq-aliasing. Reproduced with sequential, never-concurrent calls. Fix (W5-scoped; full store-side allocator unification stays deferred to #908 — detector.sh/W4 untouched): * Enumeration now FAILS CLOSED. Before writing any observed_seq, the reconciler REFUSES (loud, non-zero; the obligation is FLAGGED, never swallowed) when a seq it would allocate could be reissued/aliased: - if a detector is co-feeding this store (its private state dir exists) — ALWAYS refuse (read-only detection; W4 never touched); - else require the operator to assert reconciler-SOLE-FEEDER via --allow-enumerate / WAKE_RECONCILE_ALLOW_ENUMERATE=1. --no-enumerate and `check` never write, so they stay unconditionally safe. * Corrected the false "serialised => safe" rationale in the header comment to state the real dual-allocator hazard honestly, reference #908, and record the operational guard: a detector and the reconciler MUST NOT co-feed one store until #908 lands. * RED-FIRST test R9 reproduces the serialized-collision scenario (detector co-feeding + pre-existing state) and asserts the reconciler FAILS LOUD and enqueues NO aliasable seq (depth unchanged). Verified red: removing the guard re-enables the collision (R9 depth 0->2) and turns R9 red. R5/R6 now enumerate under an explicit sole-feeder assertion (no detector co-feeds those stores). test:framework-shell + shellcheck + firewall all green. Part of #892 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158NZqN2n2ymKFeJAZ4GUCb
This commit is contained in:
@@ -158,6 +158,9 @@ echo "== R5/R6: pre-existing state -> UNACCOUNTED flag + enumerated into store;
|
||||
"watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" }, { "kind": "repo", "id": "r2" } ] } ] }
|
||||
EOF
|
||||
export WAKE_WATCH_LIST="$wl"
|
||||
# Sole-feeder mode: no detector co-feeds this store (detector.sh is never run
|
||||
# here), so enumeration is collision-safe once the operator asserts it.
|
||||
export WAKE_RECONCILE_ALLOW_ENUMERATE=1
|
||||
[ "$(depth)" = "0" ] || fail_msg "R5: store must start empty"
|
||||
out="$("$RECON" reconcile 2>&1)"
|
||||
rc=$?
|
||||
@@ -229,6 +232,47 @@ EOF
|
||||
[ "$(depth)" = "0" ] || fail_msg "R8: a failed observation must NOT enumerate anything, got depth $(depth)"
|
||||
) && ok
|
||||
|
||||
echo "== R9: serialized dual-allocator collision -> reconciler FAILS LOUD (never silent-alias) =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state r9)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT WAKE_RECONCILE_ALLOW_ENUMERATE
|
||||
stub="$TMP_ROOT/r9stub"
|
||||
mkdir -p "$stub"
|
||||
make_stub "$stub"
|
||||
export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
|
||||
# Pre-existing sources present at startup (the reviewer's alpha/beta repro).
|
||||
printf 'ALPHA\n' >"$stub/repo_alpha"
|
||||
printf 'BETA\n' >"$stub/repo_beta"
|
||||
wl="$TMP_ROOT/r9.json"
|
||||
cat >"$wl" <<'EOF'
|
||||
{ "schema_version": 1,
|
||||
"repos": [ { "id": "alpha" }, { "id": "beta" } ],
|
||||
"watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "alpha" }, { "kind": "repo", "id": "beta" } ] } ] }
|
||||
EOF
|
||||
export WAKE_WATCH_LIST="$wl"
|
||||
# A DETECTOR is co-feeding this store: baseline it (creates the detector's
|
||||
# private-counter state dir). The detector's private observed_seq allocator is
|
||||
# now live and INDEPENDENT of the store cursor — the exact co-feeding hazard.
|
||||
"$DET" poll-once >/dev/null 2>&1 || fail_msg "R9: detector baseline failed"
|
||||
# The pre-existing state is unaccounted (baseline is silent). Enumerating it
|
||||
# here would allocate a store-cursor seq the detector's private counter can
|
||||
# later REISSUE and alias — the serialized collision. The reconciler MUST
|
||||
# refuse (fail loud), NOT enqueue a collidable seq.
|
||||
before="$(depth)"
|
||||
out="$("$RECON" reconcile 2>&1)"
|
||||
rc=$?
|
||||
[ "$rc" -ne 0 ] || fail_msg "R9: enumerating into a detector-co-fed store must FAIL LOUD (non-zero), not silently alias"
|
||||
echo "$out" | grep -qi 'dual-allocator' || fail_msg "R9: the failure must name the dual-allocator hazard [$out]"
|
||||
echo "$out" | grep -q '908' || fail_msg "R9: the failure must reference the #908 follow-up [$out]"
|
||||
echo "$out" | grep -qi 'REFUSED' || fail_msg "R9: the obligation must be REFUSED/flagged, not swallowed [$out]"
|
||||
# No collidable seq was written: depth is unchanged, so a later detector delta
|
||||
# cannot alias a reconciler-enumerated entry.
|
||||
[ "$(depth)" = "$before" ] || fail_msg "R9: a refused reconcile must NOT enqueue any (aliasable) seq, depth changed $before->$(depth)"
|
||||
# (That the sole-feeder path DOES still enumerate — i.e. the guard is not a
|
||||
# blanket no-op — is proven by R5/R6, which enumerate under --allow-enumerate.)
|
||||
) && ok
|
||||
|
||||
echo
|
||||
if [ -s "$FAILFILE" ]; then
|
||||
echo "wake reconcile harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
||||
|
||||
Reference in New Issue
Block a user