diff --git a/.agents/skills/pinpoint-huddle/SKILL.md b/.agents/skills/pinpoint-huddle/SKILL.md index 74e534dd3..8d852b6db 100644 --- a/.agents/skills/pinpoint-huddle/SKILL.md +++ b/.agents/skills/pinpoint-huddle/SKILL.md @@ -205,27 +205,29 @@ Sign with `—` (em-dash + your registered name). The se ## Scripts -| Script | Trigger | Purpose | -| ---------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------- | -| `scripts/hooks/huddle-bootstrap.sh` | manual (one-time) | Init root epic + daily + monthly + config.json | -| `scripts/hooks/huddle-rotate.sh` | rotation subagent | Phase A: atomic create-and-pointer-update; outputs OLD/NEW bead IDs | -| `scripts/hooks/huddle-poll.sh` | UserPromptSubmit + PostToolUse (throttled) | Inject new today_bead comments since last_seen | -| `scripts/hooks/huddle-session-start.sh` | SessionStart | Bootstrap notice, rotation notice, identity, summary injection | -| `scripts/hooks/huddle-pr-announce.sh` | PostToolUse (`Bash`\|`mcp__github__create_pull_request`) | Auto-post PR-open notice; dedup + fail-open | -| `scripts/hooks/huddle-whoami.sh` | manual | Register/lookup/list/discover session→name mappings | -| `scripts/hooks/huddle-rotation-check.sh` | sourced by both hooks | Date-compare: returns 0 if today_bead.date < today | -| `scripts/hooks/huddle-lib.sh` | sourced by all hooks | Shared helpers (state-dir resolver, today-bead ID resolver) | +| Script | Trigger | Purpose | +| ---------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts/hooks/huddle-bootstrap.sh` | manual (one-time) | Init root epic + daily + monthly + config.json | +| `scripts/hooks/huddle-rotate.sh` | rotation subagent | Phase A: atomic create-and-pointer-update; outputs OLD/NEW bead IDs | +| `scripts/hooks/huddle-poll.sh` | UserPromptSubmit + PostToolUse (throttled) | Inject new today_bead comments since last_seen | +| `scripts/hooks/huddle-session-start.sh` | SessionStart | Bootstrap notice, rotation notice, identity, summary injection | +| `scripts/hooks/huddle-pr-announce.sh` | PostToolUse (`Bash`\|`mcp__github__create_pull_request`) | Auto-post PR-open notice; dedup + fail-open | +| `scripts/hooks/huddle-whoami.sh` | manual | Register/lookup/list/discover session→name mappings | +| `scripts/hooks/huddle-rotation-check.sh` | sourced by both hooks | Date-compare: returns 0 if today_bead.date < today | +| `scripts/hooks/huddle-lib.sh` | sourced by all hooks | Shared helpers: state-dir + today-bead resolvers; `huddle_sync` (per-machine throttled Dolt push+pull), `huddle_discover_root` (fork-proof adopt), `huddle_reconcile_today` (cross-machine dedup) | ## State files All under `/.agents/huddle/` (shared across all linked worktrees via `git rev-parse --git-common-dir`). Git-ignored — machine-local. -| File | Purpose | -| ----------------------- | ------------------------------------------------------- | -| `config.json` | `{"root_bead_id": "PP-xxx"}` — written by bootstrap | -| `session-names.json` | `{session_id: name}` map (canonical for self-filter) | -| `last-seen-` | Per-checkout poll cursor (newest injected `created_at`) | -| `rotation.lock` | flock/lockf target for rotation serialization | +| File | Purpose | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `config.json` | `{"root_bead_id": "PP-xxx"}` — written by bootstrap, or auto-adopted on a fresh machine (see Multi-machine) | +| `session-names.json` | `{session_id: name}` map (canonical for self-filter). **Intentionally machine-local** — a session lives on one machine, and a peer machine's posts must NOT be self-filtered on yours | +| `last-seen-` | Per-checkout poll cursor (newest injected `created_at`) | +| `last-pull` | **Per-machine** Dolt-sync throttle marker (epoch seconds). Shared across all worktrees/sessions of the clone so one sync per interval serves them all | +| `pull.lock` | Non-blocking flock/lockf target so exactly one session syncs when many fire at once | +| `rotation.lock` | flock/lockf target for same-machine rotation serialization | Plus one per-worktree file outside the shared state dir: @@ -247,6 +249,37 @@ Huddle Root (epic, never closes) ── PP-XXXX └── Monthly 2026-04 ── PP-EEEE (closed; rollup summary) ``` +## Multi-machine (sync across Tim's machines) + +The huddle works across multiple machines (Mac + Bazzite) with many concurrent +sessions per machine. It rides the beads sync backbone: beads is **Dolt embedded +plus a DoltHub remote** (`sync.remote` in `.beads/config.yaml`), so the root bead, +its `notes` pointers, the daily/monthly beads, and all comments already sync via +`bd dolt push` / `bd dolt pull`. Three machine-local mechanisms make that +seamless: + +- **Throttled per-machine sync.** `huddle_sync` (in `huddle-lib.sh`, called by + the session-start and poll hooks) does `bd dolt push` then `pull`, gated by the + shared `last-pull` marker + non-blocking `pull.lock`. One sync per + `HUDDLE_SYNC_SECONDS` (default 180) per machine serves **every** session on it + — not one-per-session. Fully fail-open: offline or bd errors never block a + prompt; local coordination keeps working, and the next successful sync catches + up. Peer posts land in your context within one interval. +- **Fresh-machine auto-adopt (no fork).** `config.json` (the `root_bead_id` + pointer) is machine-local. On a machine that cloned + synced the beads but + never ran bootstrap, session-start calls `huddle_discover_root` — finds the + existing "Huddle coordination root" epic in the synced DB and **adopts** it + (writes `config.json`) instead of nagging or forking a duplicate root. + `huddle-bootstrap.sh` does the same discover-and-adopt before ever creating a + root, so it's safe to run on any machine. +- **Idempotent cross-machine rotation.** `huddle-rotate.sh` pulls first (so a + peer that already rotated is seen and this machine no-ops), then **adopts** an + existing "Huddle daily ``" instead of creating a duplicate, then pushes. + A deterministic `huddle_reconcile_today` safety-net (run at end of rotation and + once per session-start) collapses any rare double-create to the canonical + (lowest id), closing the loser — every machine picks the same winner, so they + converge. + ## Self-filter rules - Comments are filtered by exact suffix match: `—` (shorthand) or `—Claude-` (canonical). diff --git a/docs/superpowers/specs/2026-05-17-huddle-system-design.md b/docs/superpowers/specs/2026-05-17-huddle-system-design.md index 1a08463ae..a6a59ab07 100644 --- a/docs/superpowers/specs/2026-05-17-huddle-system-design.md +++ b/docs/superpowers/specs/2026-05-17-huddle-system-design.md @@ -40,6 +40,8 @@ git-ignored, shared across linked worktrees via `git rev-parse --git-common-dir` - `/.agents/huddle/config.json` — local config, holds root bead ID - `/.agents/huddle/session-names.json` — session_id → name mapping - `/.agents/huddle/last-seen-` — per-checkout poll cursor +- `/.agents/huddle/last-pull` — per-machine Dolt-sync throttle marker (§14) +- `/.agents/huddle/pull.lock` — non-blocking sync lock (§14) - `/.agents/huddle/rotation.lock` — flock target for rotation ## 3. Plugin Packaging @@ -132,18 +134,21 @@ agent_type? }` where `source` is `startup` | `resume` | `clear` | `compact`. Logic, in order: 1. **Skip if subagent:** if `transcript_path` contains `/subagents/`, exit 0. -2. **Skip if not bootstrapped:** if `config.json` is missing OR - `root_bead_id` points to a nonexistent bead, emit the "bootstrap needed" - notice (see §7.1) and exit. -3. **Rotation check:** source `huddle-rotation-check.sh`. If the active +2. **Per-machine sync** (§14.1): `huddle_sync` — throttled `bd dolt push`+`pull` + so this session opens on the freshest cross-machine state. +3. **Skip if not bootstrapped:** if `config.json` is missing, first try + `huddle_discover_root` to **auto-adopt** an already-synced root (§14.2); only + if none exists emit the "bootstrap needed" notice (see §7.1) and exit. +4. **Rotation check:** source `huddle-rotation-check.sh`. If the active `today_bead.date` ≠ today's local date, emit the "rotation needed" notice - (see §7.2) and skip steps 4-5. The lead agent dispatches a rotation - subagent before continuing. -4. **Identity announcement** (suppressed if `source == "compact"`): look up + (see §7.2) and skip the remaining steps. The lead agent dispatches a rotation + subagent before continuing. Otherwise `huddle_reconcile_today` runs once + (§14.3) to collapse any cross-machine duplicate daily. +5. **Identity announcement** (suppressed if `source == "compact"`): look up the session's name in `session-names.json`. Emit either the "registered as Claude-" block or the "registration needed" notice (see §7.3 and §7.4). -5. **Summary injection** (suppressed if `source == "compact"`): inject the +6. **Summary injection** (suppressed if `source == "compact"`): inject the monthly summary description and the descriptions of the `n_dailies_to_inject` most-recent daily beads (from `recent_dailies`). @@ -155,12 +160,14 @@ cwd, permission_mode, hook_event_name, prompt }`. Logic, in order: 1. **Skip if subagent:** if `transcript_path` contains `/subagents/`, exit 0. -2. **Skip if not bootstrapped:** if config missing, exit 0 silently (the - SessionStart hook handles the user-visible notice). -3. **Rotation check:** source `huddle-rotation-check.sh`. If rotation needed, - emit the rotation notice and skip step 4 — new comments must wait for the +2. **Per-machine sync** (§14.1): `huddle_sync` (throttled) before reading, so + peer machines' new comments are pulled in before the poll. +3. **Skip if not bootstrapped:** if config missing, exit 0 silently (the + SessionStart hook handles the user-visible notice / auto-adopt). +4. **Rotation check:** source `huddle-rotation-check.sh`. If rotation needed, + emit the rotation notice and skip the poll — new comments must wait for the new today_bead to be created. -4. **Poll for new comments:** read the current `today_bead.id` from +5. **Poll for new comments:** read the current `today_bead.id` from `config.json` + root notes. Run `bd comments --json`. Filter to comments newer than `last-seen-` and not signed by the current session (self-filter). Inject any matches as a @@ -401,12 +408,24 @@ catches it. ### 9.4 Two sessions racing rotation -Both sessions see "rotation needed" at the same time. Both dispatch a -rotation subagent. The lock (`rotation.lock`) serializes them. The +**Same machine:** Both sessions see "rotation needed" at the same time. Both +dispatch a rotation subagent. The lock (`rotation.lock`) serializes them. The second subagent acquires the lock after the first finishes, re-checks the date inside the lock, sees rotation is no longer needed, and exits 0. Idempotent. +**Different machines (see §14):** the local lock cannot serialize across +machines, so cross-machine safety is layered: (a) `huddle-rotate.sh` pulls +before the date re-check, so a machine whose peer already rotated + pushed +sees today's date and no-ops; (b) before creating the new daily it queries +root's children and **adopts** an existing "Huddle daily ``" instead of +creating a duplicate; (c) `huddle_reconcile_today` deterministically collapses +any residual duplicate (canonical = lowest id) at end of rotation and once per +session-start. The only window that produces a duplicate is two machines both +passing the date check _and_ both creating before either pushes — reconcile +cleans it on the next sync/session; rotation is lazy so the transient duplicate +is harmless. + ### 9.5 Crash during rotation If `huddle-rotate.sh` dies between creating the new today_bead and updating @@ -499,3 +518,47 @@ foundation is on main. Brand-new bead to be filed when PR #1357 lands. - **N default value** — starting at 5. Tunable in `settings.n_dailies_to_inject`. - **Lock timeout** — 60s. Larger than PP-bg45's worktree lock (30s) because this lock covers LLM-driven summarization, which can be the slowest step. + +## 14. Multi-machine synchronization (PP-lt12.38) + +The huddle runs across Tim's machines (Mac + Bazzite) with many concurrent +sessions per machine. It piggybacks on the beads sync backbone rather than +inventing its own: beads is **Dolt embedded + a DoltHub remote** (`sync.remote` +in `.beads/config.yaml`), so the root bead + its `notes` pointers, the +daily/monthly beads, and every comment already replicate via `bd dolt push` / +`bd dolt pull`. What was machine-local — and had to be fixed — was the _pointer +to the root_, the _freshness of reads_, and the _rotation lock_. + +### 14.1 Throttled per-machine sync + +`huddle_sync` (`huddle-lib.sh`) does `bd dolt push` then `pull`, gated by a +`last-pull` epoch marker in the shared state dir plus a non-blocking `pull.lock`. +Because the state dir resolves identically for every worktree/session of a clone +(`git rev-parse --git-common-dir`), the throttle is **per-machine**: one sync per +`HUDDLE_SYNC_SECONDS` (default 180) serves all sessions, and when many fire at +once exactly one holds the lock while the rest skip and read the freshly-pulled +DB. Called from session-start (before reading root notes) and the poll hook +(before fetching comments). Fully fail-open — offline/bd errors write the marker +and return 0, never blocking a prompt; the next successful sync catches up. + +### 14.2 Fork-proof discovery / auto-adopt + +`config.json` (the `root_bead_id`) is machine-local, so a fresh machine used to +see "not bootstrapped" and, if bootstrapped, forked a second root epic. +`huddle_discover_root` pulls and queries for the existing "Huddle coordination +root" epic; session-start **auto-adopts** it (writes `config.json`) when +`config.json` is missing, and `huddle-bootstrap.sh` adopts-before-create. One +canonical root, shared by every machine. + +### 14.3 Idempotent rotation + reconcile + +Covered in §9.4: pull-first, adopt-existing-daily-by-title, push-after, and a +deterministic `huddle_reconcile_today` dedup. This replaces the (single-machine) +`rotation.lock` as the _cross-machine_ guarantee; the local lock is retained only +to serialize same-machine sessions. + +### 14.4 Intentionally still machine-local + +`session-names.json` (self-filter) and `last-seen-` (poll cursor) stay +per-machine/per-checkout by design — a session lives on one machine, and a peer +machine's posts must not be self-filtered on yours. diff --git a/scripts/hooks/huddle-bootstrap.sh b/scripts/hooks/huddle-bootstrap.sh index 39f77d4f0..bebff6d89 100644 --- a/scripts/hooks/huddle-bootstrap.sh +++ b/scripts/hooks/huddle-bootstrap.sh @@ -170,6 +170,25 @@ print(json.dumps(notes)) fi fi +# --- Discover-and-adopt: never fork a duplicate root across machines --- +# Before creating a fresh root, check the synced beads DB for an existing +# "Huddle coordination root" epic (huddle_discover_root pulls first). On a +# second machine or a re-clone the root already exists remotely; adopt it by +# writing config.json instead of creating a duplicate. Only fall through to +# creation when discovery genuinely finds none (true first-ever bootstrap). +# After adopting, re-run this script to validate/self-heal the adopted root's +# notes via the idempotency path above. +EXISTING_ROOT=$(huddle_discover_root 2>/dev/null) || EXISTING_ROOT="" +if [[ -n "$EXISTING_ROOT" ]]; then + printf 'Found existing huddle root %s in the synced beads DB — adopting it (no duplicate created).\n' "$EXISTING_ROOT" + python3 -c " +import json, sys +print(json.dumps({'schema_version': 1, 'root_bead_id': sys.argv[1]}, indent=2)) +" "$EXISTING_ROOT" > "$CONFIG_FILE" + printf 'Wrote %s → %s. Re-run this script to validate the adopted root'\''s notes.\n' "$CONFIG_FILE" "$EXISTING_ROOT" + exit 0 +fi + TODAY=$(date +%F) MONTH=$(date +%Y-%m) NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ") diff --git a/scripts/hooks/huddle-lib.sh b/scripts/hooks/huddle-lib.sh index 3c03da23a..c4e30fb42 100644 --- a/scripts/hooks/huddle-lib.sh +++ b/scripts/hooks/huddle-lib.sh @@ -69,3 +69,196 @@ except Exception: [[ -n "$today_id" ]] || return 1 printf '%s' "$today_id" } + +# huddle_sync — throttled, per-machine Dolt push+pull to keep the huddle beads +# fresh across Tim's machines (Mac + Bazzite). Fail-open: any error (offline, +# no remote, bd missing, lock held) returns 0 silently and never stalls a hook +# for more than the bounded network timeout. +# +# Bounded blocking: the push/pull are synchronous, so the one session that wins +# the lock does wait on the network — but each call is wrapped in `timeout` +# (GNU `timeout`, or `gtimeout` from coreutils on macOS) capped at +# $HUDDLE_SYNC_TIMEOUT seconds (default 15). A hung remote is killed at the cap +# instead of stalling the prompt indefinitely. If neither timeout binary exists +# the calls run unwrapped (bd/dolt still apply their own network deadlines). +# +# Per-machine throttle: the marker lives in the shared huddle state dir +# (/.agents/huddle/last-pull), which every worktree/session of +# this clone resolves to identically via huddle_state_dir — so one sync per +# interval serves ALL sessions on the machine, not one-per-session. A +# non-blocking lock ensures exactly one session syncs when many fire at once; +# the rest skip and simply read the freshly-pulled local Dolt DB. +# +# Interval: $HUDDLE_SYNC_SECONDS (default 180 — matches the poll throttle). +# Push-before-pull: local coordination posts propagate first, then peers ingest. +# The marker is written INSIDE the lock BEFORE the network calls (same backoff +# discipline as huddle-poll.sh's poll throttle) so a broken remote can't cause a +# hammer loop across sessions. +# +# shellcheck disable=SC2317 # function is sourced and called by callers +huddle_sync() { + command -v bd >/dev/null 2>&1 || return 0 + local state_dir marker lockfile interval now last + state_dir=$(huddle_state_dir) || return 0 + mkdir -p "$state_dir" 2>/dev/null || return 0 + marker="$state_dir/last-pull" + lockfile="$state_dir/pull.lock" + interval="${HUDDLE_SYNC_SECONDS:-180}" + [[ "$interval" =~ ^[0-9]+$ ]] || interval=180 + local sync_timeout="${HUDDLE_SYNC_TIMEOUT:-15}" + [[ "$sync_timeout" =~ ^[0-9]+$ ]] || sync_timeout=15 + # Resolve a timeout binary once (GNU `timeout`, or `gtimeout` on macOS via + # coreutils). Empty → run the network calls unwrapped. + local timeout_bin="" + if command -v timeout >/dev/null 2>&1; then + timeout_bin="timeout" + elif command -v gtimeout >/dev/null 2>&1; then + timeout_bin="gtimeout" + fi + + # Fast throttle check (no lock): skip if the marker is fresh. + if [[ -f "$marker" ]]; then + # `read` returns non-zero on a marker with no trailing newline but still + # assigns the partial value — keep it (|| true), then validate numeric. + last=0 + read -r last < "$marker" 2>/dev/null || true + [[ "$last" =~ ^[0-9]+$ ]] || last=0 + if [[ "$last" -gt 0 ]]; then + now=$(date +%s) + (( now - last < interval )) && return 0 + fi + fi + + # The locked body re-checks the marker (a peer may have synced between our + # fast check and acquiring the lock), writes the marker, then push+pulls. + # It reads the marker path + interval from exported env vars (not positional + # args) so the `bash -c` string needs no single-quote expansion — same + # exported-function + exported-vars pattern as huddle-rotate.sh's do_rotation. + export _HS_MARKER="$marker" _HS_INTERVAL="$interval" + export _HS_TIMEOUT_BIN="$timeout_bin" _HS_TIMEOUT="$sync_timeout" + _huddle_sync_body() { + local m="$_HS_MARKER" iv="$_HS_INTERVAL" l n + if [[ -f "$m" ]]; then + l=0 + read -r l < "$m" 2>/dev/null || true + [[ "$l" =~ ^[0-9]+$ ]] || l=0 + if [[ "$l" -gt 0 ]]; then + n=$(date +%s) + (( n - l < iv )) && return 0 + fi + fi + date +%s > "$m" 2>/dev/null || true + # Wrap each network call in the resolved timeout binary (if any) so a hung + # remote is killed at the cap rather than stalling the hook. + if [[ -n "$_HS_TIMEOUT_BIN" ]]; then + "$_HS_TIMEOUT_BIN" "$_HS_TIMEOUT" bd dolt push --quiet >/dev/null 2>&1 || true + "$_HS_TIMEOUT_BIN" "$_HS_TIMEOUT" bd dolt pull --quiet >/dev/null 2>&1 || true + else + bd dolt push --quiet >/dev/null 2>&1 || true + bd dolt pull --quiet >/dev/null 2>&1 || true + fi + } + export -f _huddle_sync_body + + # Non-blocking lock — same lockf(macOS)/flock(Linux) split as huddle-rotate.sh. + # If another session holds it, skip immediately (it is doing the sync for us). + if command -v flock >/dev/null 2>&1; then + flock -n "$lockfile" bash -c '_huddle_sync_body' 2>/dev/null || true + elif command -v lockf >/dev/null 2>&1; then + lockf -t 0 "$lockfile" bash -c '_huddle_sync_body' 2>/dev/null || true + else + _huddle_sync_body + fi + unset -f _huddle_sync_body 2>/dev/null || true + unset _HS_MARKER _HS_INTERVAL _HS_TIMEOUT_BIN _HS_TIMEOUT 2>/dev/null || true + return 0 +} + +# huddle_discover_root — find an existing "Huddle coordination root" epic in the +# (synced) beads DB and print its id; empty + non-zero if none. Pulls first so a +# freshly-cloned machine sees the remote's root instead of forking a duplicate. +# Fail-open: prints nothing and returns non-zero on any error. +# +# Used by huddle-bootstrap.sh (adopt-not-create) and huddle-session-start.sh +# (fresh-machine auto-adopt when config.json is missing). +# +# shellcheck disable=SC2317 # function is sourced and called by callers +huddle_discover_root() { + command -v bd >/dev/null 2>&1 || return 1 + command -v jq >/dev/null 2>&1 || return 1 + bd dolt pull --quiet >/dev/null 2>&1 || true + local id + # Lowest id wins if (pathologically) more than one root exists — deterministic + # so every machine picks the same canonical root. + id=$(bd list --type=epic --json 2>/dev/null \ + | jq -r '[ .[] | select(.title=="Huddle coordination root" and .status!="closed") ] + | sort_by(.id) | (.[0].id // "")' 2>/dev/null) || return 1 + [[ -n "$id" ]] || return 1 + printf '%s' "$id" +} + +# huddle_reconcile_today — safety-net dedup for the rare cross-machine rotation +# race. If two machines both created a "Huddle daily " for the current +# today_bead date before either pushed, this collapses them to a deterministic +# canonical (lowest id), re-points root notes at it, closes the loser(s) with a +# merge marker, and pushes. Idempotent no-op when there is 0 or 1 daily (the +# common case → two cheap local reads, no writes). Every machine picks the same +# canonical, so all converge. Fail-open; silent (no stdout). Returns 0 always. +# +# Assumes the CALLER already did a recent `bd dolt pull` (session-start's +# huddle_sync, or huddle-rotate.sh's pull) so the local DB is fresh. +# +# shellcheck disable=SC2317 # function is sourced and called by callers +huddle_reconcile_today() { + command -v bd >/dev/null 2>&1 || return 0 + command -v jq >/dev/null 2>&1 || return 0 + local state_dir config_file root_id root_json notes_str today dailies count canon cur_today_id new_notes d + state_dir=$(huddle_state_dir) || return 0 + config_file="$state_dir/config.json" + [[ -f "$config_file" ]] || return 0 + root_id=$(jq -r '.root_bead_id // ""' "$config_file" 2>/dev/null) || return 0 + [[ -n "$root_id" ]] || return 0 + root_json=$(bd show "$root_id" --json 2>/dev/null) || return 0 + notes_str=$(printf '%s' "$root_json" | jq -r '.[0].notes // ""' 2>/dev/null) || return 0 + [[ -n "$notes_str" ]] || return 0 + today=$(printf '%s' "$notes_str" | jq -r '.today_bead.date // ""' 2>/dev/null) || return 0 + [[ -n "$today" ]] || return 0 + + # All open dailies for today's date, sorted by id (ascending → canonical first). + dailies=$(bd children "$root_id" --json 2>/dev/null \ + | jq -r --arg t "Huddle daily $today" \ + '[ .[] | select(.title==$t and .status!="closed") ] | sort_by(.id) | .[].id' 2>/dev/null) || return 0 + count=$(printf '%s\n' "$dailies" | grep -c . 2>/dev/null || true) + [[ "${count:-0}" -gt 1 ]] || return 0 # 0 or 1 daily → nothing to reconcile + + canon=$(printf '%s\n' "$dailies" | head -n1) + [[ -n "$canon" ]] || return 0 + + # Repoint FIRST, close SECOND. If we closed losers first and the repoint then + # failed, root notes could be left pointing at a now-closed daily. Repointing + # up front means the worst case is a still-open duplicate (harmless, caught on + # the next reconcile) rather than a dangling pointer. Only close duplicates + # once root is confirmed pointing at the canonical id. + cur_today_id=$(printf '%s' "$notes_str" | jq -r '.today_bead.id // ""' 2>/dev/null) || cur_today_id="" + if [[ "$cur_today_id" != "$canon" ]]; then + new_notes=$(printf '%s' "$notes_str" | jq -c --arg id "$canon" '.today_bead.id = $id' 2>/dev/null) || new_notes="" + if [[ -z "$new_notes" ]]; then + return 0 # couldn't build the repoint → leave dups open for next reconcile + fi + # `bd update` is transactional — a zero exit means the notes were written, so + # it confirms root now points at the canonical id. On any failure, skip the + # closes and leave the duplicate open for the next reconcile to collapse. + bd update "$root_id" --notes "$new_notes" >/dev/null 2>&1 || return 0 + fi + + # Root now points at the canonical → safe to close every non-canonical dup. + while IFS= read -r d; do + [[ -z "$d" ]] && continue + [[ "$d" == "$canon" ]] && continue + bd comments add "$d" "→ merged into $canon (duplicate daily reconciled across machines)" >/dev/null 2>&1 || true + bd close "$d" >/dev/null 2>&1 || true + done <<< "$dailies" + + bd dolt push --quiet >/dev/null 2>&1 || true + return 0 +} diff --git a/scripts/hooks/huddle-poll.sh b/scripts/hooks/huddle-poll.sh index a3fb0f7e1..317ef3ee9 100644 --- a/scripts/hooks/huddle-poll.sh +++ b/scripts/hooks/huddle-poll.sh @@ -168,6 +168,13 @@ source "$LIB_SCRIPT" STATE_DIR=$(huddle_state_dir) || exit 0 mkdir -p "$STATE_DIR" +# --- Per-machine Dolt sync (throttled, fail-open) --- +# Push local coordination posts and pull peer machines' updates before reading +# root notes, so this session sees the freshest cross-machine state. Throttled +# per-machine (shared marker in STATE_DIR) so many concurrent sessions trigger +# at most one sync per interval. Never blocks the prompt — fully fail-open. +huddle_sync + # --- Bootstrap check --- # If config.json is missing, the system hasn't been bootstrapped yet. # Exit silently — huddle-session-start.sh emits the user-visible bootstrap notice. diff --git a/scripts/hooks/huddle-rotate.sh b/scripts/hooks/huddle-rotate.sh index 84b1f33a2..2f6ff7bf7 100644 --- a/scripts/hooks/huddle-rotate.sh +++ b/scripts/hooks/huddle-rotate.sh @@ -72,6 +72,11 @@ fi do_rotation() { set -euo pipefail + # Pull peer machines' state before the date re-check. If another machine + # already rotated and pushed, this pull surfaces today's date and we no-op + # below — the cross-machine equivalent of the local-lock peer check. Fail-open. + bd dolt pull --quiet >/dev/null 2>&1 || true + # Re-check date inside the lock (idempotency: a peer may have rotated first). # Reserve `exit 0` for the explicit "already up to date" case below. Anything # else (bd unreachable, root bead unreadable, notes corrupted) is a hard error @@ -162,21 +167,33 @@ except Exception: month_rolled=1 fi - # Create new daily bead (orphan until root notes are updated) + # Create new daily bead (orphan until root notes are updated), OR adopt one a + # peer machine already created for today — idempotent across machines. We + # queried after the pull above, so a peer's pushed daily is visible here. local new_today_id - new_today_id=$(bd create -t task \ - --parent "$ROOT_ID" \ - --title "Huddle daily $today" \ - --description "Active coordination bead for $today. Agents post updates here. At midnight rotation this bead gets a categorized summary and closes." \ - --silent) + new_today_id=$(bd children "$ROOT_ID" --json 2>/dev/null \ + | jq -r --arg t "Huddle daily $today" \ + '[ .[] | select(.title==$t and .status!="closed") ] | sort_by(.id) | (.[0].id // "")' 2>/dev/null || echo "") + if [[ -z "$new_today_id" ]]; then + new_today_id=$(bd create -t task \ + --parent "$ROOT_ID" \ + --title "Huddle daily $today" \ + --description "Active coordination bead for $today. Agents post updates here. At midnight rotation this bead gets a categorized summary and closes." \ + --silent) + fi local new_monthly_id="$old_monthly_id" if [[ "$month_rolled" -eq 1 ]]; then - new_monthly_id=$(bd create -t task \ - --parent "$ROOT_ID" \ - --title "Huddle monthly $current_month" \ - --description "Monthly coordination summary for $current_month. Receives aggregated summaries of daily beads when they close." \ - --silent) + new_monthly_id=$(bd children "$ROOT_ID" --json 2>/dev/null \ + | jq -r --arg t "Huddle monthly $current_month" \ + '[ .[] | select(.title==$t and .status!="closed") ] | sort_by(.id) | (.[0].id // "")' 2>/dev/null || echo "") + if [[ -z "$new_monthly_id" ]]; then + new_monthly_id=$(bd create -t task \ + --parent "$ROOT_ID" \ + --title "Huddle monthly $current_month" \ + --description "Monthly coordination summary for $current_month. Receives aggregated summaries of daily beads when they close." \ + --silent) + fi fi local now @@ -210,6 +227,10 @@ print(json.dumps(notes)) # ATOMIC: update root notes — after this the system is consistent bd update "$ROOT_ID" --notes "$new_notes" + # Push the new pointers so peer machines converge fast and don't double-rotate. + # Fail-open: a transient push failure doesn't abort (pre-push + next sync catch up). + bd dolt push --quiet >/dev/null 2>&1 || true + # Post continuation comments on old beads (best-effort: failures do not abort) if [[ -n "$old_today_id" ]]; then bd comments add "$old_today_id" "→ continued in $new_today_id (rotation $today)" 2>/dev/null || true @@ -245,3 +266,15 @@ case "$LOCK_TOOL" in flock -x -w 60 "$LOCK_FILE" bash -c 'do_rotation' ;; esac + +# Safety-net dedup (runs in the parent shell, where huddle-lib.sh is sourced and +# huddle_reconcile_today is defined). If a peer machine created a duplicate daily +# for today inside the race window — both rotated before either pushed — collapse +# to the deterministic canonical. Silent + fail-open; no-op in the common case. +# +# Pull once more first: in the "both machines created before either pushed" race, +# the peer's duplicate may not be in our local DB yet, so without a fresh pull the +# reconcile would just no-op. Rotation is rare, so the extra pull is cheap and +# makes the safety-net materially more effective. Fail-open (offline → skip). +bd dolt pull --quiet >/dev/null 2>&1 || true +huddle_reconcile_today || true diff --git a/scripts/hooks/huddle-session-start.sh b/scripts/hooks/huddle-session-start.sh index 9e36498bf..aaea2cfb6 100644 --- a/scripts/hooks/huddle-session-start.sh +++ b/scripts/hooks/huddle-session-start.sh @@ -48,12 +48,31 @@ STATE_DIR=$(huddle_state_dir) || exit 0 NAMES_JSON="$STATE_DIR/session-names.json" mkdir -p "$STATE_DIR" +# --- Per-machine Dolt sync (throttled, fail-open) --- +# Pull peer machines' huddle updates (and push ours) before reading root notes, +# so this session opens with the freshest cross-machine state. Throttled +# per-machine; never blocks session start. +huddle_sync + # --- Bootstrap check --- # If config.json is missing, emit the user-visible bootstrap notice and exit. # This is the only hook that emits the notice; huddle-poll.sh exits silently. CONFIG_FILE="$STATE_DIR/config.json" ROOT_ID="" if [[ ! -f "$CONFIG_FILE" ]]; then + # Fresh-machine auto-adopt: before nagging to bootstrap, look for an existing + # "Huddle coordination root" epic in the synced beads DB. On a machine that + # cloned + synced the beads but never ran bootstrap locally, this finds the + # shared root and adopts it (writes config.json) instead of forking a + # duplicate — the multi-machine "just works" path. Only fall through to the + # bootstrap notice when discovery genuinely finds nothing (true first init). + ADOPTED_ROOT=$(huddle_discover_root 2>/dev/null) || ADOPTED_ROOT="" + if [[ -n "$ADOPTED_ROOT" ]]; then + printf '{"schema_version": 1, "root_bead_id": "%s"}\n' "$ADOPTED_ROOT" > "$CONFIG_FILE" 2>/dev/null || true + ROOT_ID="$ADOPTED_ROOT" + fi +fi +if [[ -z "$ROOT_ID" && ! -f "$CONFIG_FILE" ]]; then MAIN_ROOT=$(dirname "$(git rev-parse --git-common-dir 2>/dev/null || echo ".")" 2>/dev/null || echo "") printf '## ⚠️ Huddle not bootstrapped\n\n' printf 'The huddle coordination system is not set up yet. It maintains a daily bead\n' @@ -141,6 +160,13 @@ except Exception: fi fi +# --- Cross-machine dedup safety-net (once per session, up-to-date path only) --- +# huddle_sync above already pulled, so the local DB is fresh. If the rare +# midnight race left two open dailies for today (two machines rotated before +# either pushed), collapse them to the canonical here. No-op in the common case +# (two cheap local reads); silent + fail-open. +huddle_reconcile_today || true + SESSION_ID="" SOURCE="" if [[ -n "$INPUT" ]]; then diff --git a/scripts/tests/test_huddle_sync.py b/scripts/tests/test_huddle_sync.py new file mode 100644 index 000000000..98b3b165e --- /dev/null +++ b/scripts/tests/test_huddle_sync.py @@ -0,0 +1,260 @@ +"""Unit tests for the multi-machine helpers in scripts/hooks/huddle-lib.sh. + +Covers huddle_sync (throttled, per-machine Dolt push+pull), huddle_discover_root +(fork-proof root discovery), and huddle_reconcile_today (cross-machine duplicate +daily dedup). Each test builds a throwaway git repo (so huddle_state_dir resolves +to /.agents/huddle), stubs `bd` on PATH to record its invocations and emit +canned JSON, then sources the lib and calls the function under test. +""" + +import json +import os +import stat +import subprocess +import tempfile +from collections.abc import Iterator +from pathlib import Path + +import pytest + +LIB_PATH = Path(__file__).parent.parent / "hooks" / "huddle-lib.sh" +TODAY = "2026-07-06" + +# Stub `bd`: append the full arg list to $BD_LOG, and for read subcommands emit +# the JSON in the file named by the matching env var. Everything else exits 0. +BD_STUB = r"""#!/usr/bin/env bash +printf '%s\n' "$*" >> "$BD_LOG" +case "$1 $2" in + "dolt push") exit "${BD_PUSH_RC:-0}" ;; + "dolt pull") exit "${BD_PULL_RC:-0}" ;; +esac +case "$1" in + list) [[ -n "${BD_LIST_JSON:-}" ]] && cat "$BD_LIST_JSON"; exit 0 ;; + children) [[ -n "${BD_CHILDREN_JSON:-}" ]] && cat "$BD_CHILDREN_JSON"; exit 0 ;; + show) [[ -n "${BD_SHOW_JSON:-}" ]] && cat "$BD_SHOW_JSON"; exit 0 ;; +esac +exit 0 +""" + + +@pytest.fixture +def repo() -> Iterator[Path]: + """A temp git repo with .agents/huddle/config.json and a stub bd on PATH.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + huddle = root / ".agents" / "huddle" + huddle.mkdir(parents=True) + (huddle / "config.json").write_text( + json.dumps({"schema_version": 1, "root_bead_id": "PP-lt12"}) + ) + bindir = root / "bin" + bindir.mkdir() + bd = bindir / "bd" + bd.write_text(BD_STUB) + bd.chmod(bd.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + yield root + + +def run_fn( + repo: Path, call: str, env_extra: dict[str, str] | None = None +) -> tuple[int, str, str, str]: + """Source the lib and run `call`. Returns (rc, stdout, stderr, bd_log).""" + log = repo / "bd.log" + env = os.environ.copy() + env["PATH"] = f"{repo / 'bin'}{os.pathsep}{env['PATH']}" + env["BD_LOG"] = str(log) + if env_extra: + env.update(env_extra) + proc = subprocess.run( + ["bash", "-c", f"source '{LIB_PATH}'; {call}"], + cwd=repo, + env=env, + capture_output=True, + text=True, + ) + bd_log = log.read_text() if log.exists() else "" + return proc.returncode, proc.stdout, proc.stderr, bd_log + + +# --- huddle_sync ----------------------------------------------------------- + + +def test_sync_fires_when_marker_missing(repo: Path) -> None: + rc, _out, _err, log = run_fn(repo, "huddle_sync") + assert rc == 0 + assert "dolt push" in log + assert "dolt pull" in log + # Marker written so the next call within the interval is throttled. + assert (repo / ".agents" / "huddle" / "last-pull").exists() + + +def test_sync_throttled_when_marker_fresh(repo: Path) -> None: + marker = repo / ".agents" / "huddle" / "last-pull" + # Fresh marker (now) → within the default 180s window → skip entirely. + rc, _out, _err, log = run_fn( + repo, "printf '%s' \"$(date +%s)\" > '.agents/huddle/last-pull'; huddle_sync" + ) + assert rc == 0 + assert "dolt" not in log + assert marker.exists() + + +def test_sync_fires_when_marker_stale(repo: Path) -> None: + # Marker far in the past → past the window → sync. + rc, _out, _err, log = run_fn( + repo, "printf '1' > '.agents/huddle/last-pull'; huddle_sync" + ) + assert rc == 0 + assert "dolt push" in log and "dolt pull" in log + + +def test_sync_custom_interval_zero_always_fires(repo: Path) -> None: + rc, _out, _err, log = run_fn( + repo, + "printf '%s' \"$(date +%s)\" > '.agents/huddle/last-pull'; huddle_sync", + {"HUDDLE_SYNC_SECONDS": "0"}, + ) + assert rc == 0 + # Interval 0 → even a just-written marker is not "fresh" → sync fires. + assert "dolt push" in log + + +def test_sync_fail_open_when_push_and_pull_error(repo: Path) -> None: + # Offline simulation: push/pull exit non-zero. huddle_sync must still + # return 0 (never block a hook) and still write the throttle marker. + rc, _out, _err, log = run_fn( + repo, "huddle_sync", {"BD_PUSH_RC": "1", "BD_PULL_RC": "1"} + ) + assert rc == 0 + assert "dolt push" in log # it tried + assert (repo / ".agents" / "huddle" / "last-pull").exists() + + +# --- huddle_discover_root -------------------------------------------------- + + +def test_discover_root_finds_existing(repo: Path) -> None: + epics = repo / "epics.json" + epics.write_text( + json.dumps( + [ + {"id": "PP-zz", "title": "Some other epic", "status": "open"}, + { + "id": "PP-lt12", + "title": "Huddle coordination root", + "status": "open", + }, + ] + ) + ) + rc, out, _err, _log = run_fn( + repo, "huddle_discover_root", {"BD_LIST_JSON": str(epics)} + ) + assert rc == 0 + assert out.strip() == "PP-lt12" + + +def test_discover_root_lowest_id_wins(repo: Path) -> None: + epics = repo / "epics.json" + epics.write_text( + json.dumps( + [ + { + "id": "PP-lt99", + "title": "Huddle coordination root", + "status": "open", + }, + { + "id": "PP-lt12", + "title": "Huddle coordination root", + "status": "open", + }, + ] + ) + ) + rc, out, _err, _log = run_fn( + repo, "huddle_discover_root", {"BD_LIST_JSON": str(epics)} + ) + assert rc == 0 + assert out.strip() == "PP-lt12" + + +def test_discover_root_none_returns_nonzero(repo: Path) -> None: + epics = repo / "epics.json" + epics.write_text(json.dumps([{"id": "PP-zz", "title": "Other", "status": "open"}])) + rc, out, _err, _log = run_fn( + repo, "huddle_discover_root", {"BD_LIST_JSON": str(epics)} + ) + assert rc != 0 + assert out.strip() == "" + + +# --- huddle_reconcile_today ------------------------------------------------ + + +def _root_show(today_id: str) -> str: + notes = { + "schema_version": 1, + "today_bead": {"id": today_id, "date": TODAY}, + "monthly_bead": {"id": "PP-m", "month": "2026-07"}, + "recent_dailies": [{"id": today_id, "date": TODAY}], + "settings": {}, + "last_rotation": "2026-07-06T00:00:00Z", + } + return json.dumps([{"id": "PP-lt12", "notes": json.dumps(notes)}]) + + +def test_reconcile_collapses_duplicate_dailies(repo: Path) -> None: + show = repo / "show.json" + show.write_text(_root_show("PP-lt12.99")) # notes point at the HIGHER id + children = repo / "children.json" + children.write_text( + json.dumps( + [ + { + "id": "PP-lt12.99", + "title": f"Huddle daily {TODAY}", + "status": "open", + }, + { + "id": "PP-lt12.40", + "title": f"Huddle daily {TODAY}", + "status": "open", + }, + ] + ) + ) + rc, _out, _err, log = run_fn( + repo, + "huddle_reconcile_today", + {"BD_SHOW_JSON": str(show), "BD_CHILDREN_JSON": str(children)}, + ) + assert rc == 0 + # Canonical = lowest id (PP-lt12.40). The higher-id dup is closed... + assert "close PP-lt12.99" in log + assert "close PP-lt12.40" not in log + # ...and root notes are re-pointed at the canonical, then pushed. + assert "update PP-lt12 --notes" in log + assert "dolt push" in log + + +def test_reconcile_noop_when_single_daily(repo: Path) -> None: + show = repo / "show.json" + show.write_text(_root_show("PP-lt12.40")) + children = repo / "children.json" + children.write_text( + json.dumps( + [{"id": "PP-lt12.40", "title": f"Huddle daily {TODAY}", "status": "open"}] + ) + ) + rc, _out, _err, log = run_fn( + repo, + "huddle_reconcile_today", + {"BD_SHOW_JSON": str(show), "BD_CHILDREN_JSON": str(children)}, + ) + assert rc == 0 + # No writes at all in the common case. + assert "close" not in log + assert "update" not in log + assert "dolt push" not in log