From e52aa6a4a31c38eaefc7e119a44e28da33c1fbcd Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 18 Oct 2026 16:13:59 -0700 Subject: [PATCH] feat: live-sync heartbeat commits machine-written paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checkout IS the deployment and symlinks make live state (memories, auto-learned skills, agent config, desktop settings) dirty tracked files continuously; no commit law covered machine churn, so it accumulated unpushed for weeks — one disk held the only copy of ~61 managed skills, a settings fix stayed local-only, and dirty trees refused merges. scripts/live-sync.sh (systemd user timer, ~15 min) commits ONLY the allowlisted machine/desktop-live tiers with hooks armed, merges origin/main, pushes, and reaps wt/* worktrees older than 7 days. Task dirt outside the allowlist is never staged; conflicts abort and alert via ntfy; branch guard refuses off main; never --no-verify, never force. Black-box contract pinned by tests/live-sync.bats (9 tests, suite 36/36). Refs #11 --- AGENTS.md | 19 ++ .../.config/systemd/user/live-sync.service | 12 ++ .../.config/systemd/user/live-sync.timer | 11 ++ omp-docs/log.md | 4 + omp-docs/tools/index.md | 1 + omp-docs/tools/live-sync.md | 76 ++++++++ scripts/live-sync.sh | 131 +++++++++++++ tests/live-sync.bats | 176 ++++++++++++++++++ 8 files changed, 430 insertions(+) create mode 100644 dotfiles/systemd-user/.config/systemd/user/live-sync.service create mode 100644 dotfiles/systemd-user/.config/systemd/user/live-sync.timer create mode 100644 omp-docs/tools/live-sync.md create mode 100755 scripts/live-sync.sh create mode 100755 tests/live-sync.bats diff --git a/AGENTS.md b/AGENTS.md index 4351b70..8b89db9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,25 @@ commit"). Applied here: gate is the point of the hook). - Messages: imperative mood, what + why, `Refs #N` when a Gitea issue exists. +### Live-sync tiers (the machine is a committer too — Refs #11) + +The checkout is the deployment, so several tracked files are live state +written continuously through symlinks by the harness, auto-learn, and +desktop GUIs. "Dirty tree" is judged by the writer: + +| Tier | Paths | Commit law | +|------|-------|------------| +| Human/agent prose | `AGENTS.md`, `omp-docs/`, `scripts/`, `plugins/`, `agent/skills/` | branch → PR → Frank OK (unchanged) | +| Machine live | `agent/memories/`, `agent/managed-skills/`, `agent/config.yml`, `agent/models.yml` | `scripts/live-sync.sh` heartbeat | +| Desktop live | `dotfiles/` | same heartbeat | + +The heartbeat (systemd user timer, ~15 min) stages ONLY allowlisted tiers, +commits with hooks armed — the secret gate included — merges `origin/main`, +pushes, and reaps `wt/*` worktrees older than 7 days. Non-allowlist dirt is +never committed, only surfaced. Conflicts abort and alert (ntfy); it never +force-pushes, auto-resolves, or uses `--no-verify`. Details: +[omp-docs/tools/live-sync.md](omp-docs/tools/live-sync.md). + ## Secret gate (HARD rule) The pre-commit hook (`.githooks/pre-commit`, enabled via diff --git a/dotfiles/systemd-user/.config/systemd/user/live-sync.service b/dotfiles/systemd-user/.config/systemd/user/live-sync.service new file mode 100644 index 0000000..dcbe80d --- /dev/null +++ b/dotfiles/systemd-user/.config/systemd/user/live-sync.service @@ -0,0 +1,12 @@ +[Unit] +Description=command-center live-sync heartbeat (commits machine-written paths) +Documentation=file://%h/.omp/omp-docs/tools/live-sync.md + +[Service] +# One-shot heartbeat: commit allowlisted live paths, merge trunk, push, reap +# stale wt/* worktrees. Self-healing by design — a failed fetch/merge alerts +# via ntfy and simply retries on the next tick; nothing to restart. +Type=oneshot +Nice=19 +IOSchedulingClass=idle +ExecStart=%h/.omp/scripts/live-sync.sh diff --git a/dotfiles/systemd-user/.config/systemd/user/live-sync.timer b/dotfiles/systemd-user/.config/systemd/user/live-sync.timer new file mode 100644 index 0000000..adbfd15 --- /dev/null +++ b/dotfiles/systemd-user/.config/systemd/user/live-sync.timer @@ -0,0 +1,11 @@ +[Unit] +Description=command-center live-sync heartbeat every 15 minutes + +[Timer] +OnBootSec=5min +OnUnitActiveSec=15min +RandomizedDelaySec=3min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/omp-docs/log.md b/omp-docs/log.md index 82841dd..8fd64d9 100644 --- a/omp-docs/log.md +++ b/omp-docs/log.md @@ -11,6 +11,10 @@ timestamp: 2026-08-28 # Bundle Change Log +## 2026-10-18 — live-sync heartbeat for machine-written paths (Refs #11) + +Root-caused the month of silent divergence in the live checkout (~61 auto-learned skills on one disk only, a stranded `unexpectedStopDetection` fix, merge-refusals from dirty trees): the checkout IS the deployment, symlinks make machine writes dirty tracked files by design, and no commit law covered that churn. Added `scripts/live-sync.sh` + systemd user timer — allowlist tiers (memories, managed-skills, live config, dotfiles) auto-commit with hooks armed, merge `origin/main`, push, and reap stale `wt/*` worktrees; everything outside the allowlist is never staged, conflicts abort-and-alert. Black-box suite `tests/live-sync.bats` (9 tests). Tier law in AGENTS.md § Live-sync tiers; reference page `tools/live-sync.md` registered in the tools index. + ## 2026-10-18 — Fleet law: filed PRs open in Frank's browser immediately Both repos' git rules now require it: the moment a PR is opened, `xdg-open ` drops a Firefox tab on the workstation (AGENTS.md § Trunk-based SDLC here; § Git Workflow step 6 in `dank/serverAppConfigs`, riding their PR #6 branch). diff --git a/omp-docs/tools/index.md b/omp-docs/tools/index.md index fee4d75..d6314c8 100644 --- a/omp-docs/tools/index.md +++ b/omp-docs/tools/index.md @@ -8,6 +8,7 @@ Configuration guides and reference material for installed software. Each tool ha * [[cachyos|cachyos]] — Arch-based rolling release with performance-optimized kernels, repositories, and gaming stack. Running on the workstation. * [comfyui](comfyui/index.md) — Node-based AI image generation engine on dankclaw. FLUX.2 klein 4B, CPU backend, systemd managed. +* [live-sync](live-sync.md) — Heartbeat daemon committing machine-written paths (memories, managed-skills, live config, dotfiles) in the command-center checkout; systemd user timer, alerts via ntfy. * [[applications|applications]] — Default userspace applications on CachyOS with Niri — browsers, editor, terminal, file manager, audio, screenshots, gaming. * ](ghostty/index.md) — GPU-accelerated terminal emulator with extensive configuration, keybindings, and shaders. * ](gitea/index.md) — Private Git hosting on dankclaw. Quadlet-managed containers: Gitea, PostgreSQL, Caddy. diff --git a/omp-docs/tools/live-sync.md b/omp-docs/tools/live-sync.md new file mode 100644 index 0000000..32e4c87 --- /dev/null +++ b/omp-docs/tools/live-sync.md @@ -0,0 +1,76 @@ +--- +type: reference +title: live-sync heartbeat +description: Auto-commit daemon for machine-written paths in the command-center checkout — the deployment is live, so symlinks make config churn dirty tracked files continuously. +tags: + - git + - automation + - systemd + - deployment + - fleet +verified: 2026-10-18 +timestamp: 2026-10-18 +--- + +# live-sync heartbeat + +The command-center checkout IS the deployment, and several tracked files are +live state: the harness writes memories and managed-skills, the operator +edits `config.yml`/`models.yml` through the `cfg://` surface, and desktop +GUIs (Noctalia, niri) rewrite dotfiles through symlinks. Before Refs #11 this +machine churn just piled up unpushed — one disk holding the only copy of ~61 +auto-learned skills, a settings fix stranded locally, and merge-refusals +because the dirty tree collided with incoming work. + +`scripts/live-sync.sh` is the pressure valve: a heartbeat that commits the +machine's own writes on a schedule. Tier law (which paths it may touch) lives +in [AGENTS.md](../../AGENTS.md) § Live-sync tiers. + +## Contract (black-box) + +Run = one pass: flock → branch guard (`main` only) → stage allowlisted dirty +tiers → commit (hooks armed) → fetch + merge `origin/main` → push → reap +stale `wt/*` worktrees. Exit `0` = quiet heartbeat (synced/no-op/reaped); +exit `1` = an alert was raised. Pinned by `tests/live-sync.bats`. + +Hard properties: + +- **Allowlist only.** Task dirt (`plugins/`, `omp-docs/`, prose) is never + staged — merge conflicts with in-flight work become impossible by + construction. +- **Hooks stay armed.** A secret-gate refusal aborts the commit, leaves + changes staged for a human, and alerts. Never `--no-verify`, never force. +- **Never fights the remote.** Merge conflict → `merge --abort`, local + commit kept, alert. Retries are automatic next tick. +- **Branch guard.** On a non-`main` checkout it refuses and alerts — same + rule as the doctrine ("never end a session off main"). +- **Polite reaper.** `wt/wt-*` worktrees untouched for 7+ days are removed + with their `wt/*` branch; dirty or process-occupied worktrees are skipped. + +## Alerts + +`notify()` writes stderr always; pushes to ntfy when the topic file +`~/.config/ntfy/live-sync` (mode 600, topic text only) exists, throttled to +one push per hour. Test seams: `LIVE_SYNC_REPO`, `LIVE_SYNC_NOTIFY`, +`LIVE_SYNC_NTFY_TOPIC_FILE`, `LIVE_SYNC_AGE_DAYS`. + +## Install + +Units ship in `dotfiles/systemd-user/.config/systemd/user/` and propagate +with the usual dotfiles chain: + +```bash +systemctl --user enable --now live-sync.timer # after the unit is linked +systemctl --user start live-sync.service # one manual heartbeat +journalctl --user -u live-sync.service -n 20 # observe +``` + +## Failure modes + +| Event | Result | +|-------|--------| +| Harness mid-write | commits a valid snapshot; remainder lands next heartbeat | +| Gitea down | commit safe locally; push retries next tick (≤15 min stale window) | +| Same file edited on two boxes | real merge conflict → ntfy → human resolves (visible, not silent) | +| Feature branch checked out in main checkout | refuses + alerts; protects the never-end-off-main rule | +| Secret shape in machine-written content | hook blocks, changes stay staged, alert | diff --git a/scripts/live-sync.sh b/scripts/live-sync.sh new file mode 100755 index 0000000..0743012 --- /dev/null +++ b/scripts/live-sync.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# live-sync.sh — heartbeat committer for machine-written paths in the command +# center checkout. Refs #11 (see omp-docs/tools/live-sync.md). +# +# The checkout IS the deployment; the harness, auto-learn, and desktop GUIs +# write tracked files continuously through symlinks. This script commits ONLY +# the allowlisted machine-live + desktop-live tiers (AGENTS.md § Live-sync +# tiers), merges the trunk, pushes, and reaps stale session worktrees. Task +# dirt outside the allowlist is NEVER committed — only surfaced. +# +# Black-box contract (tests/live-sync.bats pins it): +# input git state of $LIVE_SYNC_REPO +# output exit 0 = quiet heartbeat (synced, no-op, or reaped) +# exit 1 = alert raised (branch guard, hook refusal, merge conflict, +# fetch/push failure); alert goes to stderr + notify sink +# Env overrides (test seams): +# LIVE_SYNC_REPO target repo (default $HOME/.omp) +# LIVE_SYNC_NOTIFY alert command, message on stdin +# LIVE_SYNC_NTFY_TOPIC_FILE ntfy topic file, mode 600 (default +# ~/.config/ntfy/live-sync; absent/empty = no push) +# LIVE_SYNC_AGE_DAYS wt/wt-* reap threshold (default 7) +set -euo pipefail + +REPO="${LIVE_SYNC_REPO:-$HOME/.omp}" +AGE_DAYS="${LIVE_SYNC_AGE_DAYS:-7}" +TOPIC_FILE="${LIVE_SYNC_NTFY_TOPIC_FILE:-$HOME/.config/ntfy/live-sync}" +STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}" + +# Allowlist tiers. Human prose (AGENTS.md, omp-docs, scripts, plugins, +# agent/skills) is deliberately absent: branch -> PR territory, never swept. +LIVE_PATHS=( + agent/memories + agent/managed-skills + agent/config.yml + agent/models.yml + dotfiles +) + +notify() { + local msg="live-sync: $1" stamp now + stamp=$(cat "$STATE_DIR/live-sync.alert" 2>/dev/null || echo 0) + now=$(date +%s) + if (( now - stamp >= 3600 )); then + date +%s > "$STATE_DIR/live-sync.alert" + if [[ -n "${LIVE_SYNC_NOTIFY:-}" ]]; then + printf '%s' "$msg" | $LIVE_SYNC_NOTIFY || true + elif [[ -s "$TOPIC_FILE" ]]; then + curl -fsS -m 10 -d "$msg" "https://ntfy.sh/$(<"$TOPIC_FILE")" >/dev/null 2>&1 || true + fi + fi + printf '%s\n' "$msg" >&2 +} + +cd "$REPO" 2>/dev/null || exit 0 +exec 9>"$STATE_DIR/live-sync.lock" +flock -n 9 || exit 0 # another heartbeat owns the repo +git rev-parse --verify -q HEAD >/dev/null || exit 0 + +branch=$(git symbolic-ref --quiet --short HEAD || true) +if [[ "$branch" != main ]]; then + notify "on branch '$branch', not main — refusing to sync, nothing changed" + exit 1 +fi + +# 1. stage only allowlisted paths that actually hold dirt (pathspecs that +# match nothing would make git add fatal, so check per-path) +for p in "${LIVE_PATHS[@]}"; do + if [[ -n $(git status --porcelain -- "$p") ]]; then + git add -A -- "$p" + fi +done + +if ! git diff --cached --quiet; then + # 2. commit WITH hooks armed — the secret gate stays pointed at the diff; + # a refusal here is the system working, never bypassed + if ! git commit -q -m "live: heartbeat sync $(date -Is) (Refs #11)"; then + notify "commit rejected by hook/secret gate — live changes left STAGED for a human" + exit 1 + fi + + # 3. integrate trunk and publish; never fight the remote + if git remote get-url origin >/dev/null 2>&1; then + if ! git fetch -q origin; then + notify "fetch failed — commit is safe locally, will retry next heartbeat" + exit 1 + fi + if git rev-parse --verify -q origin/main >/dev/null \ + && ! git merge -q --no-edit origin/main; then + git merge --abort || true + notify "merge conflict with origin/main — local commit kept; needs you at the keyboard" + exit 1 + fi + if ! git push -q origin main; then + notify "push failed — commit is safe locally, will retry next heartbeat" + exit 1 + fi + fi +fi + +# 4. reap session worktrees older than AGE_DAYS. Dirty worktrees are left +# alone (git refuses), and so are any with a live process cwd inside. +in_use() { + local p + for p in /proc/[0-9]*/cwd; do + if [[ "$(readlink "$p" 2>/dev/null || true)" == "$1"* ]]; then + return 0 + fi + done + return 1 +} + +git worktree prune +reap_path="" reap_ref="" +reap_flush() { + if [[ "$reap_path" == "$REPO/wt/wt-"* && -d "$reap_path" ]] \ + && [[ -z $(find "$reap_path" -mtime "-$AGE_DAYS" -print -quit) ]] \ + && ! in_use "$reap_path"; then + git worktree remove -- "$reap_path" 2>/dev/null && git worktree prune || true + if [[ -n "$reap_ref" ]]; then + git branch -q -d "${reap_ref#refs/heads/}" 2>/dev/null || true + fi + fi + reap_path="" reap_ref="" +} +while IFS= read -r line; do + case "$line" in + "worktree "*) reap_flush; reap_path="${line#worktree }" ;; + "branch "*) reap_ref="${line#branch }" ;; + esac +done < <(git worktree list --porcelain) +reap_flush diff --git a/tests/live-sync.bats b/tests/live-sync.bats new file mode 100755 index 0000000..74e9a8a --- /dev/null +++ b/tests/live-sync.bats @@ -0,0 +1,176 @@ +#!/usr/bin/env bats +# tests/live-sync.bats — black-box tests for scripts/live-sync.sh (Refs #11) +# +# Contract (see script header): the heartbeat commits ONLY allowlisted live +# paths, merges origin/main, pushes, and reaps stale wt/* worktrees. Nothing +# is observed except exit status, stderr, the notify-sink log, and git state. + +setup() { + TEST_TMP="$(mktemp -d)" + REPO="$TEST_TMP/repo" + export XDG_RUNTIME_DIR="$TEST_TMP/xdg" + mkdir -p "$XDG_RUNTIME_DIR" + export GIT_CONFIG_GLOBAL="$TEST_TMP/gitconfig" GIT_CONFIG_SYSTEM=/dev/null + export GIT_AUTHOR_DATE="2026-10-18T12:00:00+00:00" GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE" + export LIVE_SYNC_REPO="$REPO" + export LIVE_SYNC_NTFY_TOPIC_FILE="$TEST_TMP/no-topic-file" + export LIVE_SYNC_NOTIFY="$TEST_TMP/notify-stub" + export LIVE_SYNC_AGE_DAYS=7 + + # notify sink: appends each alert line to alerts.log + cat > "$LIVE_SYNC_NOTIFY" <> "$TEST_TMP/alerts.log" +echo >> "$TEST_TMP/alerts.log" +STUB + chmod +x "$LIVE_SYNC_NOTIFY" + + # bare "origin" + seeded clone with all tiers represented + git init -q --bare -b main "$TEST_TMP/origin.git" + git clone -q "$TEST_TMP/origin.git" "$REPO" + git -C "$REPO" config user.email frank@dank + git -C "$REPO" config user.name Frank + git -C "$REPO" checkout -q -b main + mkdir -p "$REPO/agent/memories" "$REPO/agent/managed-skills/s1" \ + "$REPO/dotfiles/bash" "$REPO/plugins/bench" "$REPO/scripts" + echo base > "$REPO/agent/config.yml" + echo base > "$REPO/agent/models.yml" + echo base > "$REPO/agent/memories/learned.md" + echo base > "$REPO/agent/managed-skills/s1/SKILL.md" + echo base > "$REPO/dotfiles/bash/bashrc" + echo base > "$REPO/AGENTS.md" + echo base > "$REPO/scripts/deploy.sh" + echo base > "$REPO/plugins/bench/server.py" + git -C "$REPO" add -A + git -C "$REPO" commit -q -m seed + git -C "$REPO" push -q origin main + SEED="$(git -C "$REPO" rev-parse HEAD)" +} + +teardown() { rm -rf "$TEST_TMP"; } + +@test "heartbeat commits allowlisted live dirt only; task dirt stays dirty" { + echo new > "$REPO/agent/memories/learned.md" + mkdir "$REPO/agent/managed-skills/s2" + echo new > "$REPO/agent/managed-skills/s2/SKILL.md" + echo new > "$REPO/AGENTS.md" + echo new > "$REPO/plugins/bench/server.py" + + run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh" + [ "$status" -eq 0 ] + + # exactly one new commit ahead of the seed + [ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 1 ] + # it touches ONLY live paths (untracked new dirs picked up too) + run git -C "$REPO" show --pretty=format: --name-only HEAD + printf '%s\n' "${lines[@]}" | grep -v '^$' | sort | diff -q - <( + printf '%s\n' agent/managed-skills/s2/SKILL.md agent/memories/learned.md) + # task dirt untouched, uncommitted + [ -n "$(git -C "$REPO" status --porcelain -- AGENTS.md plugins)" ] + # pushed: origin caught up + [ "$(git -C "$REPO" rev-parse origin/main)" = "$(git -C "$REPO" rev-parse main)" ] +} + +@test "clean tree is a silent no-op" { + run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh" + [ "$status" -eq 0 ] + [ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 0 ] + [ ! -e "$TEST_TMP/alerts.log" ] +} + +@test "non-allowlist dirt alone never commits" { + echo new > "$REPO/AGENTS.md" + echo new > "$REPO/scripts/deploy.sh" + run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh" + [ "$status" -eq 0 ] + [ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 0 ] + [ -n "$(git -C "$REPO" status --porcelain -- AGENTS.md scripts)" ] +} + +@test "merge conflict aborts, keeps local commit, alerts, leaves remote alone" { + # a second clone advances origin/main on the same live file + git clone -q -b main "$TEST_TMP/origin.git" "$TEST_TMP/pocket" + git -C "$TEST_TMP/pocket" config user.email pocket@dank + git -C "$TEST_TMP/pocket" config user.name Pocket + echo remote-side > "$TEST_TMP/pocket/agent/models.yml" + git -C "$TEST_TMP/pocket" commit -qam "remote advance" + git -C "$TEST_TMP/pocket" push -q origin main + + echo local-side > "$REPO/agent/models.yml" + + run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"merge conflict"* ]] + [ -s "$TEST_TMP/alerts.log" ] + # HEAD is my local commit, merge left no residue, remote untouched + [[ "$(git -C "$REPO" log --format=%s -1)" == live:*"heartbeat sync"* ]] + [ ! -e "$REPO/.git/MERGE_HEAD" ] + [ "$(git -C "$REPO" rev-parse origin/main)" = "$(git -C "$TEST_TMP/pocket" rev-parse main)" ] +} + +@test "branch guard refuses to sync on a feature branch" { + git -C "$REPO" checkout -q -b feature/whatever + echo new > "$REPO/agent/config.yml" + + run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"not main"* ]] + [ "$(git -C "$REPO" rev-list --count "$SEED"..feature/whatever)" -eq 0 ] + [ -n "$(git -C "$REPO" status --porcelain -- agent/config.yml)" ] +} + +@test "pre-commit hook refusal is surfaced, not bypassed; changes stay staged" { + mkdir "$TEST_TMP/hooks" + printf '#!/bin/sh\nexit 1\n' > "$TEST_TMP/hooks/pre-commit" + chmod +x "$TEST_TMP/hooks/pre-commit" + git -C "$REPO" config core.hooksPath "$TEST_TMP/hooks" + echo new > "$REPO/dotfiles/bash/bashrc" + + run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"rejected"* ]] + [ -n "$(git -C "$REPO" diff --cached --name-only -- dotfiles)" ] + [ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 0 ] +} + +@test "repo without origin still commits locally and exits clean" { + git -C "$REPO" remote remove origin + echo new > "$REPO/dotfiles/bash/bashrc" + run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh" + [ "$status" -eq 0 ] + [ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 1 ] +} + +@test "stale wt/* worktree and its branch are reaped; fresh one survives" { + git -C "$REPO" worktree add -q wt/wt-old -b wt/old + git -C "$REPO" worktree add -q wt/wt-new -b wt/new + touch -d "10 days ago" "$REPO/wt/wt-old" + find "$REPO/wt/wt-old" -exec touch -d "10 days ago" {} + + + run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh" + [ "$status" -eq 0 ] + [ ! -d "$REPO/wt/wt-old" ] + [ -d "$REPO/wt/wt-new" ] + run git -C "$REPO" show-ref --verify refs/heads/wt/old + [ "$status" -ne 0 ] + git -C "$REPO" show-ref --verify --quiet refs/heads/wt/new +} + +@test "second heartbeat inside the throttle window alerts once, never twice" { + # two conflicting heartbeats: alert sink must see exactly one line + git clone -q -b main "$TEST_TMP/origin.git" "$TEST_TMP/pocket" + git -C "$TEST_TMP/pocket" config user.email pocket@dank + git -C "$TEST_TMP/pocket" config user.name Pocket + echo remote-side > "$TEST_TMP/pocket/agent/config.yml" + git -C "$TEST_TMP/pocket" commit -qam "remote advance" + git -C "$TEST_TMP/pocket" push -q origin main + echo local-side > "$REPO/agent/config.yml" + + run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh" + [ "$status" -eq 1 ] + rm -f "$XDG_RUNTIME_DIR/live-sync.lock" # flock releases with the process; state stays + echo local-side2 > "$REPO/agent/config.yml" + run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh" + [ "$status" -eq 1 ] + [ "$(wc -l < "$TEST_TMP/alerts.log")" -eq 1 ] +}