From 302b062680c8fbd9743d4e083154ac5fc314955a Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 20 Jul 2026 16:03:08 -0500 Subject: feat(pager): document the Signal pager and add the receive timer The pager worked but was undocumented and drifting stale: signal-cli warned the account had gone 47 days without a receive, and no runbook existed to hand a future session or a non-Claude runtime. I added the runbook under docs/design/ and a roam-sync-shaped receive timer that drains the queue every 15 minutes, keeping the account warm and surfacing reply messages. I also generalized agent-page: it now sends directly from any machine holding the pager account and relays to velox only when it doesn't. ratio is linked as a device of the account, so a page lands even when velox is down. The receive script no-ops cleanly where the account isn't registered, since the units stow onto every machine via the shared common package. --- scripts/signal-receive.sh | 51 ++++++++++++++++++++++++++ scripts/systemd/signal-receive.service | 10 ++++++ scripts/systemd/signal-receive.timer | 10 ++++++ scripts/tests/agent-page.bats | 34 +++++++++--------- scripts/tests/signal-receive.bats | 66 ++++++++++++++++++++++++++++++++++ 5 files changed, 155 insertions(+), 16 deletions(-) create mode 100755 scripts/signal-receive.sh create mode 100644 scripts/systemd/signal-receive.service create mode 100644 scripts/systemd/signal-receive.timer create mode 100644 scripts/tests/signal-receive.bats (limited to 'scripts') diff --git a/scripts/signal-receive.sh b/scripts/signal-receive.sh new file mode 100755 index 0000000..8c3ef01 --- /dev/null +++ b/scripts/signal-receive.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# signal-receive.sh — drain the Signal pager account's inbound queue. +# +# The pager identity (+15045173983) lives on velox (primary) and any linked +# device (ratio). The Signal protocol expects a registered account to receive +# regularly; when it goes quiet, signal-cli prints a staleness warning +# ("Messages have been last received N days ago") and the account drifts toward +# an unhealthy state. This script pulls anything queued and exits, keeping the +# account warm. Run by the signal-receive systemd user timer (scripts/systemd/) +# on every machine holding the account — the roam-sync-shaped fix for the +# receive-staleness caveat on the pager channel. +# +# Draining the queue is also what surfaces Craig's replies: a reply he types in +# Signal arrives as a data message here, so a warm account is a prerequisite for +# the read-replies half of the pager (see docs/design/…-signal-pager-runbook.org). +# +# The units stow via the shared dotfiles `common` package, so the timer may land +# on a machine that does not hold the account. That is fine: the script no-ops +# cleanly (exit 0) when the account is not registered locally, rather than +# erroring every cadence. +# +# Usage: signal-receive.sh [account] [timeout-seconds] +# account defaults to the pager identity below +# timeout-seconds seconds to wait for new messages (default 10) +# +# Exit: 0 on a clean receive (including "nothing queued" and "account not on this +# machine"), non-zero only if signal-cli itself errors, so a real failure is +# visible in `systemctl --user status signal-receive`. + +set -euo pipefail + +account="${1:-+15045173983}" +timeout="${2:-10}" + +if ! command -v signal-cli >/dev/null 2>&1; then + echo "signal-receive: signal-cli not on PATH — nothing to do" >&2 + exit 0 +fi + +# No-op cleanly where the account isn't registered (a machine that stows the +# common units but isn't a pager device). Only a genuine receive error should +# surface as a failure. +if ! signal-cli listAccounts 2>/dev/null | grep -q "$account"; then + echo "signal-receive: $account not registered on this machine — nothing to do" >&2 + exit 0 +fi + +# `receive` prints envelopes to stdout and returns 0 once the queue drains or +# the timeout elapses. --send-read-receipts tells Craig's phone his replies were +# read by the pager, matching normal Signal behavior. +exec signal-cli -a "$account" receive --timeout "$timeout" --send-read-receipts diff --git a/scripts/systemd/signal-receive.service b/scripts/systemd/signal-receive.service new file mode 100644 index 0000000..d63a5c8 --- /dev/null +++ b/scripts/systemd/signal-receive.service @@ -0,0 +1,10 @@ +# Signal pager receive — keep the pager account warm on every machine holding it. +# Stowed via the dotfiles `common` package, so it lands on both daily drivers; +# the script no-ops cleanly where the account isn't registered. Enable per machine: +# systemctl --user daemon-reload && systemctl --user enable --now signal-receive.timer +[Unit] +Description=Drain the Signal pager account inbound queue (keeps it warm) + +[Service] +Type=oneshot +ExecStart=%h/code/rulesets/scripts/signal-receive.sh diff --git a/scripts/systemd/signal-receive.timer b/scripts/systemd/signal-receive.timer new file mode 100644 index 0000000..3fdaee0 --- /dev/null +++ b/scripts/systemd/signal-receive.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Keep the Signal pager account warm every 15 minutes + +[Timer] +OnBootSec=3min +OnUnitActiveSec=15min +RandomizedDelaySec=60 + +[Install] +WantedBy=timers.target diff --git a/scripts/tests/agent-page.bats b/scripts/tests/agent-page.bats index 071e4b9..7317ac7 100644 --- a/scripts/tests/agent-page.bats +++ b/scripts/tests/agent-page.bats @@ -1,9 +1,11 @@ #!/usr/bin/env bats # agent-page — the runtime-neutral phone pager. Pages Craig over Signal from -# any machine on the tailnet: runs signal-cli directly on velox (where the -# pager identity lives), ssh-relays to velox from everywhere else. These tests -# stub ssh/uname/signal-cli on PATH to verify command construction without a -# network or a phone. +# any machine on the tailnet: sends directly wherever the pager account is +# registered locally (velox, or any linked device), and ssh-relays to velox +# from a machine that doesn't hold the account. These tests stub +# ssh/signal-cli on PATH to verify command construction without a network or +# a phone. The signal-cli stub answers `listAccounts` to control which branch +# the dispatch takes. setup() { REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" @@ -15,8 +17,14 @@ setup() { echo "ssh \$*" >> "$LOG" exit 0 EOF + # HAS_ACCOUNT controls the listAccounts answer: "1" → the pager account is + # local (send direct), unset/"0" → not local (relay). cat > "$STUBS/signal-cli" <> "$LOG" exit 0 EOF @@ -33,8 +41,8 @@ teardown() { [[ "$output" == *"usage"* ]] } -@test "relays through ssh to velox with the pager account and Craig's UUID" { - PATH="$STUBS:$PATH" run bash "$PAGE" build finished +@test "relays through ssh to velox when the account is not local" { + HAS_ACCOUNT=0 PATH="$STUBS:$PATH" run bash "$PAGE" build finished [ "$status" -eq 0 ] grep -q "^ssh .*velox" "$LOG" grep -q "15045173983" "$LOG" @@ -43,16 +51,10 @@ teardown() { grep -qF 'build\ finished' "$LOG" } -@test "on velox itself, calls signal-cli directly (no ssh)" { - cat > "$STUBS/uname" <<'EOF' -#!/bin/bash -[ "$1" = "-n" ] && { echo velox; exit 0; } -exec /usr/bin/uname "$@" -EOF - chmod +x "$STUBS/uname" - PATH="$STUBS:$PATH" run bash "$PAGE" hello +@test "sends directly (no ssh) when the pager account is registered locally" { + HAS_ACCOUNT=1 PATH="$STUBS:$PATH" run bash "$PAGE" hello [ "$status" -eq 0 ] - grep -q "^signal-cli " "$LOG" + grep -q "^signal-cli -a +15045173983 send " "$LOG" ! grep -q "^ssh " "$LOG" } @@ -62,7 +64,7 @@ EOF exit 255 EOF chmod +x "$STUBS/ssh" - PATH="$STUBS:$PATH" run bash "$PAGE" urgent thing + HAS_ACCOUNT=0 PATH="$STUBS:$PATH" run bash "$PAGE" urgent thing [ "$status" -ne 0 ] [[ "$output" == *"notify"* ]] } diff --git a/scripts/tests/signal-receive.bats b/scripts/tests/signal-receive.bats new file mode 100644 index 0000000..8fe4d67 --- /dev/null +++ b/scripts/tests/signal-receive.bats @@ -0,0 +1,66 @@ +#!/usr/bin/env bats +# signal-receive.sh — drains the Signal pager account's inbound queue to keep it +# warm (the roam-sync-shaped fix for receive-staleness) and to surface Craig's +# replies. Run on velox by the signal-receive systemd timer. These tests stub +# signal-cli on PATH to verify command construction without a network or the +# real account. + +setup() { + REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" + RECV="$REPO_ROOT/scripts/signal-receive.sh" + STUBS="$(mktemp -d)" + LOG="$STUBS/calls.log" + # HAS_ACCOUNT (default 1) controls the listAccounts answer so the local-account + # guard can be exercised: "1" lists both test accounts as present, "0" lists + # none (so the guard no-ops). + cat > "$STUBS/signal-cli" <> "$LOG" +exit 0 +EOF + chmod +x "$STUBS/signal-cli" +} + +teardown() { + rm -rf "$STUBS" +} + +@test "defaults to the pager account, a 10s timeout, and read receipts" { + PATH="$STUBS:$PATH" run bash "$RECV" + [ "$status" -eq 0 ] + grep -q -- "-a +15045173983 receive --timeout 10 --send-read-receipts" "$LOG" +} + +@test "honors an explicit account and timeout" { + PATH="$STUBS:$PATH" run bash "$RECV" +19995550000 25 + [ "$status" -eq 0 ] + grep -q -- "-a +19995550000 receive --timeout 25" "$LOG" +} + +@test "no-ops (exit 0) when the account is not registered on this machine" { + # Guards the runbook claim that the timer no-ops on a common-package machine + # that stows the units but holds no pager account. + HAS_ACCOUNT=0 PATH="$STUBS:$PATH" run bash "$RECV" + [ "$status" -eq 0 ] + [[ "$output" == *"not registered"* ]] + # The receive must not have run. + ! grep -q "receive" "$LOG" +} + +@test "no-ops (exit 0) when signal-cli is not on PATH" { + # Empty stub dir with no signal-cli; echo/exit/command are bash builtins, so + # the script still runs and hits its signal-cli-absent branch, which no-ops. + rm -f "$STUBS/signal-cli" + # Invoke bash by absolute path so `run` finds it regardless of the empty + # PATH the script itself sees. + PATH="$STUBS" run "$(command -v bash)" "$RECV" + [ "$status" -eq 0 ] + [[ "$output" == *"signal-cli"* ]] +} -- cgit v1.2.3