#!/usr/bin/env bash # ack.sh — A4 of the wake canon (EPIC #892, W2): the RECEIVED / CONSUMED # ack-wrapper. # # CONTRACT ANCHORS (docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md): # §2.2 ack protocol: RECEIVED vs CONSUMED; local-write + async ship. # §1.2 three-cursor: consumed_seq advances ONLY on a consumer CONSUMED ack. # # RECEIVED — delivery happened (paste landed). `wake_id` dedups DELIVERY ONLY: # a duplicate delivery is a re-RECEIVE, NEVER a re-action. # CONSUMED N — emitted ONLY after durable capture OR no-op disposition of a # CONTIGUOUS prefix <=N. first-action-before-capture is FORBIDDEN # (caller discipline: capture BEFORE invoking this). Cannot ack N # while N-1 is unconsumed (the store enforces the gapless prefix). # Acks are CUMULATIVE: CONSUMED N implies all <=N. # # LOCAL-WRITE-ONLY: the ack path writes the XDG ledger and advances the cursor # with NO network call. A BACKGROUND sync ships the ack (WAKE_ACK_SYNC_CMD). # Ack-path outage => a single re-wake after timeout then fall back to cadence # (no retry spam) — the background sync runs ONCE and records an outage marker; # it never loops. # # The `embed` subcommand prints the one copy-run line meant to be EMBEDDED in # each digest (W3 renders it into the digest body). # # Operator-agnostic: ledger 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)" STORE_SH="$SCRIPT_DIR/store.sh" _need_jq() { command -v jq >/dev/null 2>&1 || { echo "ack.sh: jq is required" >&2 exit 3 } } usage() { cat >&2 <<'EOF' Usage: ack.sh [options] Commands: received --wake-id ID Record a RECEIVED ack (delivery). The wake_id DEDUPS DELIVERY only: a repeat is a re-RECEIVE (prints DUP), never a re-action. Local-write only. consumed --upto N [--wake-id ID] Record a CONSUMED ack for the contiguous prefix <=N and advance consumed_seq. Local-write only; a background sync ships it (never blocks on network). --no-sync suppresses the background ship. embed --upto N [--wake-id ID] [--agent A] Print the copy-run ack line to EMBED in a digest (does not perform the ack). --agent bakes an EXPLICIT `WAKE_AGENT=A ` prefix onto the emitted line (render-time namespace), so an ENV-LESS copy-run still resolves to the correct per-agent queue instead of silently falling back to `default` (#914). The value is shell-quoted (`printf %q`) so it can never inject additional shell syntax when the line is later copy-run; the caller is expected to have already scrubbed it (control/ANSI/secrets) the same way any other inlined digest value is scrubbed. status Print ledger tail + cursor state. Environment: WAKE_STATE_HOME override base state dir (XDG by default). WAKE_AGENT per-agent queue namespace (default: default). WAKE_ACK_SYNC_CMD command run in the BACKGROUND to ship an ack. Receives the ack JSON on stdin. Never invoked on the synchronous path. WAKE_ACK_CLI override the embedded copy-run invocation prefix (default: the resolved path to this script). EOF } # _ledger_append JSON — append one ack record to the local ledger, atomically. # §2.2: LOCAL-WRITE-ONLY. No network here. _ledger_append() { local record="$1" _wake_init_dir "$STATE_DIR" { cat "$STATE_DIR/ack-ledger.jsonl" 2>/dev/null printf '%s\n' "$record" } | grep -v '^[[:space:]]*$' | _atomic_write "$STATE_DIR/ack-ledger.jsonl" } # _wake_id_seen ID — true if a RECEIVED record for this wake_id already exists. # Count-based (jq -s slurp): an EMPTY ledger slurps to [] => length 0. (jq -e # over a zero-input file returns exit 0, which would false-positive here — so # never rely on -e exit semantics for presence.) _wake_id_seen() { local id="$1" n [ -f "$STATE_DIR/ack-ledger.jsonl" ] || return 1 n="$(jq -s --arg id "$id" \ '[.[] | select(.type=="RECEIVED" and .wake_id==$id)] | length' \ "$STATE_DIR/ack-ledger.jsonl" 2>/dev/null || echo 0)" [ "${n:-0}" -gt 0 ] } # _ship_async JSON — fire the background sync ONCE, fully detached, so the ack # path returns immediately and NEVER blocks on the network (§2.2). No retry # loop: a failure records an outage marker and stops (single re-wake + cadence # fallback is the escalation policy, handled off this path). _ship_async() { local record="$1" [ -n "${WAKE_ACK_SYNC_CMD:-}" ] || return 0 local marker="$STATE_DIR/ack-sync.state" ( if printf '%s\n' "$record" | sh -c "$WAKE_ACK_SYNC_CMD" >/dev/null 2>&1; then printf 'shipped %s\n' "$(date +%s)" >"$marker" 2>/dev/null || true else printf 'outage %s\n' "$(date +%s)" >"$marker" 2>/dev/null || true fi ) /dev/null 2>&1 & # Detach so no shell job-control state ties the ack turn to the ship. disown 2>/dev/null || true } cmd_received() { _need_jq local wake_id='' while [ $# -gt 0 ]; do case "$1" in --wake-id) wake_id="${2:-}" shift 2 ;; *) echo "ack.sh received: unknown option '$1'" >&2 exit 2 ;; esac done [ -n "$wake_id" ] || { echo "ack.sh received: --wake-id is required" >&2 exit 2 } _wake_init_dir "$STATE_DIR" # wake_id dedups DELIVERY only. A duplicate delivery is a re-RECEIVE — logged, # but flagged dup so no consumer re-action is triggered. It NEVER advances # consumed_seq (delivery != consumption). local dup="false" verb="RECEIVED" if _wake_id_seen "$wake_id"; then dup="true" verb="DUP" fi local record record="$(jq -cn \ --arg id "$wake_id" \ --argjson dup "$dup" \ --argjson ts "$(date +%s)" \ '{type:"RECEIVED", wake_id:$id, dup:$dup, ts:$ts}')" _ledger_append "$record" echo "$verb" } cmd_consumed() { _need_jq local upto='' wake_id='' do_sync="1" while [ $# -gt 0 ]; do case "$1" in --upto) upto="${2:-}" shift 2 ;; --wake-id) wake_id="${2:-}" shift 2 ;; --no-sync) do_sync="0" shift ;; *) echo "ack.sh consumed: unknown option '$1'" >&2 exit 2 ;; esac done case "$upto" in '' | *[!0-9]*) echo "ack.sh consumed: --upto must be a non-negative integer" >&2 exit 2 ;; esac # Advance consumed_seq via the store. The store enforces the CONTIGUOUS # gapless-prefix rule and rejects a gap (cannot ack N while N-1 unconsumed). # This is a LOCAL-WRITE cursor advance — no network. local new_cursor if ! new_cursor="$("$STORE_SH" consume --upto "$upto" 2>&1)"; then echo "ack.sh consumed: refused — $new_cursor" >&2 exit 1 fi # Record the CONSUMED ack in the local ledger (still no network). local record record="$(jq -cn \ --argjson upto "$upto" \ --arg id "$wake_id" \ --argjson ts "$(date +%s)" \ '{type:"CONSUMED", upto:$upto, wake_id:$id, ts:$ts}')" _ledger_append "$record" # Only now, OFF the ack path, ship it in the background. The synchronous # portion is already complete and durable. if [ "$do_sync" = "1" ]; then _ship_async "$record" fi echo "CONSUMED $new_cursor" } cmd_embed() { local upto='' wake_id='' agent='' while [ $# -gt 0 ]; do case "$1" in --upto) upto="${2:-}" shift 2 ;; --wake-id) wake_id="${2:-}" shift 2 ;; --agent) agent="${2:-}" shift 2 ;; *) echo "ack.sh embed: unknown option '$1'" >&2 exit 2 ;; esac done case "$upto" in '' | *[!0-9]*) echo "ack.sh embed: --upto must be a non-negative integer" >&2 exit 2 ;; esac # The one copy-run line a digest embeds. WAKE_ACK_CLI lets an installer point # it at the deployed path; default is this script's resolved path. local cli="${WAKE_ACK_CLI:-$SCRIPT_DIR/ack.sh}" # #914a: bake the render-time agent in as an EXPLICIT `WAKE_AGENT= ` # prefix, so an ENV-LESS copy-run resolves to the SAME per-agent namespace # the digest was rendered for, instead of silently falling back to `default` # (wake_state_dir() resolves purely from ${WAKE_AGENT:-default} at RUN time, # which — absent this prefix — has no relation to the agent the digest was # rendered for). `printf %q` shell-quotes the value so it is embedded as one # opaque token and can never inject additional shell syntax into the line # when it is later copy-run, even if the (caller-scrubbed) agent value still # carries shell metacharacters. This is purely additive: no --agent => the # line is byte-identical to before (existing direct callers unaffected). local prefix='' if [ -n "$agent" ]; then local agent_q printf -v agent_q '%q' "$agent" prefix="WAKE_AGENT=${agent_q} " fi if [ -n "$wake_id" ]; then printf '%s%s consumed --upto %s --wake-id %s\n' "$prefix" "$cli" "$upto" "$wake_id" else printf '%s%s consumed --upto %s\n' "$prefix" "$cli" "$upto" fi } cmd_status() { _wake_init_dir "$STATE_DIR" echo "# cursors" "$STORE_SH" cursors echo "# ack-ledger (tail)" tail -n 10 "$STATE_DIR/ack-ledger.jsonl" 2>/dev/null || true if [ -f "$STATE_DIR/ack-sync.state" ]; then echo "# sync" cat "$STATE_DIR/ack-sync.state" fi } main() { [ $# -ge 1 ] || { usage exit 2 } local cmd="$1" shift case "$cmd" in received) cmd_received "$@" ;; consumed) cmd_consumed "$@" ;; embed) cmd_embed "$@" ;; status) cmd_status "$@" ;; -h | --help | help) usage ;; *) echo "ack.sh: unknown command '$cmd'" >&2 usage exit 2 ;; esac } main "$@"