watchdog: fix handoff lag — detect on pushed origin/main + precise formal-claim match

The handoff pings fired on the writer's LOCAL working-tree write (before push), so the receiver
pulled a stale origin/main, saw "no formal gate", and a clarifying inbox round-trip ensued
(several minutes + wasted turns per handoff). And the gate-id parser read "WC1" as "C1" and could
fire on prose mentions.

Fix (1): handoff_check now `git fetch`es and reads origin/main (what the receiver will pull), via
_wd_fetch_origin + _wd_show_pushed, for STATUS / REVIEW / both INBOXes — a ping only fires once the
claim/verdict is actually pushed, so the receiver's pull always sees it. Eliminates the stale-pull
"premature" dance.
Fix (2): gate-claim detection matches ONLY a formal line (Gate: <id> … CLAIMED, awaiting Adversary)
and edge-triggers on a genuinely-new such line compared whole — no firing on historical
"CLAIMED detail" lines or prose; gate-id is a best-effort label only.

Loops' clones have a credential helper (reads .testenv) so the watchdog's fetch works
non-interactively. Verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 02:38:47 +01:00
parent 9f99b134cd
commit e0e60bc2bc

View File

@ -217,60 +217,70 @@ heal_orchestrator() {
"$ORCH_LAUNCHER" start >/dev/null 2>&1 || true
}
# Edge-triggered handoff signalling for the CURRENT phase. Reads the loops' local clones.
# Ping the Adversary only when a gate id NEWLY appears on a "CLAIMED … awaiting" line (never on
# the baseline / restart / a passed-but-kept line). Ping the Builder when the phase REVIEW changes.
# Detect handoffs against the PUSHED origin/main — i.e. exactly what the RECEIVER will pull — NOT the
# writer's local working tree. (Reading the working tree fired on a claim/verdict the writer hadn't
# pushed yet; the receiver then pulled a stale remote, saw "no formal gate", and a clarifying
# inbox round-trip ensued. Mirroring origin/main eliminates that race.) origin/main is the shared
# branch, so all four files are read from one clone's origin/main after a single best-effort fetch.
_wd_fetch_origin() { git -C "$1" fetch -q origin 2>/dev/null || true; }
_wd_show_pushed() { git -C "$1" show "origin/main:machine-docs/$2" 2>/dev/null || git -C "$1" show "origin/main:$2" 2>/dev/null || true; }
_wd_awaiting=""; _wd_baselined=""; _wd_last_review=""
_wd_adv_inbox_seen=""; _wd_builder_inbox_seen=""
handoff_reset() { _wd_awaiting=""; _wd_baselined=""; _wd_last_review=""; _wd_adv_inbox_seen=""; _wd_builder_inbox_seen=""; } # call on phase transition
handoff_check() {
local idx sf rf cur now added
local idx status review adv_inbox builder_inbox now added ids cur h
idx="$(cur_idx)"
sf="$(resolve_state "$BUILDER_DIR" "$(phase_status "$idx")")"; rf="$(resolve_state "$ADV_DIR" "$(phase_review "$idx")")"
if [[ -f "$sf" ]]; then
now="$(grep -iE 'CLAIMED.*awaiting' "$sf" 2>/dev/null | grep -oiE 'M[0-9]+(\.[0-9]+)?|[A-Z][0-9]+' | tr '[:lower:]' '[:upper:]' | sort -u || true)"
if [[ -n "$_wd_baselined" ]]; then
added="$(comm -13 <(printf '%s\n' "$_wd_awaiting" | sort -u) <(printf '%s\n' "$now" | sort -u) | grep -vE '^$' || true)"
if [[ -n "$added" ]]; then
log "handoff: gate(s) newly awaiting verification: $(echo $added) -> pinging Adversary"
ping_session "$ADV_SESSION" "watchdog ping: the Builder CLAIMED gate(s) [$(echo $added)] in $(phase_status "$idx") and is awaiting your verification. Pull and verify now."
fi
_wd_fetch_origin "$BUILDER_DIR"
status="$(_wd_show_pushed "$BUILDER_DIR" "$(phase_status "$idx")")"
review="$(_wd_show_pushed "$BUILDER_DIR" "$(phase_review "$idx")")"
adv_inbox="$(_wd_show_pushed "$BUILDER_DIR" "ADVERSARY-INBOX.md")"
builder_inbox="$(_wd_show_pushed "$BUILDER_DIR" "BUILDER-INBOX.md")"
# Gate claims: match ONLY a FORMAL claim line ("Gate: <id> … CLAIMED, awaiting Adversary"), and
# edge-trigger on a genuinely-NEW such line (compared whole, so prose mentions / historical
# "CLAIMED detail" lines / id mis-parsing can't fire it). The id is just a best-effort label.
now="$(printf '%s\n' "$status" | grep -iE 'gate[: ].*claimed.*awaiting[[:space:]]+adversary' | sort -u || true)"
if [[ -n "$_wd_baselined" ]]; then
added="$(comm -13 <(printf '%s\n' "$_wd_awaiting") <(printf '%s\n' "$now") | grep -vE '^$' || true)"
if [[ -n "$added" ]]; then
ids="$(printf '%s\n' "$added" | grep -oiE 'gate:?[[:space:]]*[A-Z]{1,3}[0-9]+(\.[0-9]+)*' | sed -E 's/^[Gg]ate:?[[:space:]]*//' | tr '\n' ' ' | tr '[:lower:]' '[:upper:]' || true)"
[[ -z "$ids" ]] && ids="a gate"
log "handoff: NEW pushed gate claim [$ids] -> pinging Adversary"
ping_session "$ADV_SESSION" "watchdog ping: the Builder pushed a gate claim [$ids] in $(phase_status "$idx") — awaiting your verification. Pull and verify now."
fi
_wd_awaiting="$now"; _wd_baselined=1
fi
if [[ -f "$rf" ]]; then
cur="$(md5sum "$rf" 2>/dev/null | awk '{print $1}' || true)"
_wd_awaiting="$now"; _wd_baselined=1
# REVIEW change (pushed) -> ping Builder.
if [[ -n "$review" ]]; then
cur="$(printf '%s' "$review" | md5sum | awk '{print $1}')"
if [[ -n "$cur" && "$cur" != "$_wd_last_review" ]]; then
[[ -n "$_wd_last_review" ]] && {
log "handoff: $(phase_review "$idx") changed -> pinging Builder"
ping_session "$BUILDER_SESSION" "watchdog ping: the Adversary updated $(phase_review "$idx") (a verdict or finding). Pull and act now — if it PASSes your gate, proceed; if it's a finding, address it."
log "handoff: $(phase_review "$idx") changed (pushed) -> pinging Builder"
ping_session "$BUILDER_SESSION" "watchdog ping: the Adversary pushed an update to $(phase_review "$idx") (a verdict or finding). Pull and act now — if it PASSes your gate, proceed; if it's a finding, address it."
}
_wd_last_review="$cur"
fi
fi
# INBOX side-channel (§6.1). The sender writes the receiver's inbox in their OWN clone, so we
# detect from the sender side. Edge-trigger on content hash so a fresh message (sender re-wrote
# before receiver consumed) re-pings. Receiver deletes after processing => hash empty => next
# write re-triggers.
local adv_inbox builder_inbox h
adv_inbox="$(resolve_state "$BUILDER_DIR" "ADVERSARY-INBOX.md")"
if [[ -f "$adv_inbox" ]]; then
h="$(md5sum "$adv_inbox" 2>/dev/null | awk '{print $1}' || true)"
if [[ -n "$h" && "$h" != "$_wd_adv_inbox_seen" ]]; then
log "handoff: ADVERSARY-INBOX.md new/changed -> pinging Adversary"
ping_session "$ADV_SESSION" "watchdog ping: the Builder wrote machine-docs/ADVERSARY-INBOX.md — pull, read the message, act on it, then delete the file (commit + push) to mark it consumed."
# INBOX side-channel (§6.1), detected on the pushed state. Receiver deletes after consuming =>
# absent on origin/main => re-arm so the next write re-pings.
if [[ -n "$adv_inbox" ]]; then
h="$(printf '%s' "$adv_inbox" | md5sum | awk '{print $1}')"
if [[ "$h" != "$_wd_adv_inbox_seen" ]]; then
log "handoff: ADVERSARY-INBOX.md new/changed (pushed) -> pinging Adversary"
ping_session "$ADV_SESSION" "watchdog ping: the Builder pushed machine-docs/ADVERSARY-INBOX.md — pull, read it, act, then delete the file (commit + push) to mark it consumed."
_wd_adv_inbox_seen="$h"
fi
else
_wd_adv_inbox_seen="" # consumed; ready for the next write
_wd_adv_inbox_seen=""
fi
builder_inbox="$(resolve_state "$ADV_DIR" "BUILDER-INBOX.md")"
if [[ -f "$builder_inbox" ]]; then
h="$(md5sum "$builder_inbox" 2>/dev/null | awk '{print $1}' || true)"
if [[ -n "$h" && "$h" != "$_wd_builder_inbox_seen" ]]; then
log "handoff: BUILDER-INBOX.md new/changed -> pinging Builder"
ping_session "$BUILDER_SESSION" "watchdog ping: the Adversary wrote machine-docs/BUILDER-INBOX.md — pull, read the message, act on it, then delete the file (commit + push) to mark it consumed."
if [[ -n "$builder_inbox" ]]; then
h="$(printf '%s' "$builder_inbox" | md5sum | awk '{print $1}')"
if [[ "$h" != "$_wd_builder_inbox_seen" ]]; then
log "handoff: BUILDER-INBOX.md new/changed (pushed) -> pinging Builder"
ping_session "$BUILDER_SESSION" "watchdog ping: the Adversary pushed machine-docs/BUILDER-INBOX.md — pull, read it, act, then delete the file (commit + push) to mark it consumed."
_wd_builder_inbox_seen="$h"
fi
else