Skip to content

Harden PR-loop tick: deterministic loop-tick.sh with single verdict, in-flight detection, spawn lock - #82

Merged
robercano-ghbot merged 2 commits into
mainfrom
feat/issue-81-harden-loop-tick
Jul 8, 2026
Merged

Harden PR-loop tick: deterministic loop-tick.sh with single verdict, in-flight detection, spawn lock#82
robercano-ghbot merged 2 commits into
mainfrom
feat/issue-81-harden-loop-tick

Conversation

@robercano-ghbot

Copy link
Copy Markdown
Collaborator

Closes #81

What this does

Hardens the self-hosted PR-loop tick so the protocol lives in scripts, not model discipline (root cause of the 2026-07-08 incident: fabricated telemetry, missed approvals, and a duplicate orchestrator for issue #78).

Deliverables

  1. .claude/scripts/loop-tick.sh (new) — single-entry tick. Runs loop-census.sh -> notify-poll.sh -> merge-ready.sh -> pr-feedback.sh in order with full outputs preserved, then emits exactly one machine-readable verdict as the last line: action=none | action=advance issue=N | action=feedback pr=N. Precedence: feedback (lowest-numbered PR with unaddressed CHANGES_REQUESTED) > advance (census advance_ready) > none. Calls the existing scripts — no duplicated merge/poll logic. The driver's job collapses to: run one script, spawn the orchestrator only when the verdict says so.
  2. In-flight detection in loop-census.sh — prints an explicit in_flight=N line when a feat/issue-N-* branch exists but has no open PR, so a driver never misreads an in-progress orchestration as stuck. Existing output keys unchanged.
  3. Spawn lockflock-guarded state file (.claude/state/loop-advance.lock, gitignored) recording the in-flight issue + UTC timestamp when ADVANCE is emitted. A second ADVANCE for the same issue is refused by script logic. Self-healing invariant (documented in a comment): the lock clears once an open PR exists for N, or the branch appears (in_flight), or — crashed-spawn recovery — the lock ages past LOCK_TTL_SECONDS=900 (15 min) while still advance_ready with no branch. flock closes the concurrent-tick TOCTOU race.
  4. Docs — model-selection guidance in docs/GETTING_STARTED.md / docs/USAGE.md: drive tick sessions on Sonnet, reserve Fable/Opus for scoping/planning/filing, and references loop-tick.sh.

Tests

  • loop-tick.test.sh — verdict precedence, lowest-PR tiebreak, lock write/refuse, TTL self-heal, real concurrent-tick flock serialization, and full-output passthrough of all four steps.
  • loop-census.test.sh (new) — runs the real census against a throwaway git repo: in_flight detection, origin/ prefix stripping, and issue-4 vs issue-42/43 prefix-collision guard.
  • All assertions mutation-verified non-vacuous. Gates (GATES_FILE=.claude/self/gates.json): build, lint, test_affected all pass.

Review

Two adversarial lenses, consensus=all: correctness (opus) and tests (sonnet) both APPROVE after one reject/fix round (fixed a lock deadlock, a TOCTOU race, a SIGPIPE footgun, and two vacuous-test gaps).

Notes for the owner

  • Prior-session uncommitted work (loop-census.sh untracked; docs/* modified) was seeded onto this branch and is included here. Merge caveat: because those files are still uncommitted in your local main working tree, git pull/ff-only after merge will complain they "would be overwritten" — git checkout -- docs/... && rm the local untracked loop-census.sh (or stash) so the merged versions take.
  • New tooling dependency for the self-hosted loop only: flock and GNU date -d (the latter already used by notify-poll.sh). Downstream consumer projects don't run these scripts.

🤖 Generated with Claude Code

robercano and others added 2 commits July 8, 2026 15:47
… spawn lock

Collapses the multi-step PR-loop tick (census, poll, merge, feedback-check)
into one script that computes a single machine-readable verdict
(action=none/advance/feedback) instead of leaving that arithmetic to be
re-derived from a prompt on every firing — the tick used to drift under
smaller/cheaper models (fabricated step output, double-spawned
orchestrators). loop-census.sh now reports in_flight=N (a branch exists but
no PR yet), and loop-tick.sh adds a self-healing spawn lock
(.claude/state/loop-advance.lock) so a second tick can't double-spawn an
orchestrator for an issue already mid-flight. Fixes #81.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0135JVnX98DjPxTRzQZRy82q
… (issue #81 re-review)

Re-review of the loop-tick hardening (10abb0d) found the spawn lock's own
INVARIANT backwards: it was retained precisely when advance_ready==lock_issue,
i.e. the no-branch-yet state census reports for a crashed spawn — so a crashed
orchestrator wedged the issue forever with only manual `rm` as recovery.

- loop-tick.sh: the lock is now released when (a) an open PR exists, (b) a
  branch exists (in_flight — the pre-branch race is over), or (c) the lock is
  older than LOCK_TTL_SECONDS=900 with the issue STILL advance_ready and no
  branch — treated as a crashed spawn and cleared to allow re-advance. The
  15-minute TTL comfortably exceeds a real orchestrator's branch-push latency.
- loop-tick.sh: the whole read-check-write critical section around the lock
  is now serialized with `flock` on a dedicated .claude/state/loop-advance.flock,
  closing a TOCTOU race where two overlapping ticks could both observe "no
  lock" and both emit `action=advance issue=N` (atomic temp+mv only prevented
  a torn read, not two racing readers).
- loop-census.sh: `git branch -a --list ... | head -1` under `set -euo
  pipefail` could abort the whole script on git's SIGPIPE (exit 141) when
  head closes the pipe early; `|| true` absorbs the non-fatal pipeline
  failure. Reproduced and verified against a repo with 5000 matching branches.
- docs/USAGE.md: reconciled the spawn-lock description with the corrected
  invariant.

Tests: loop-tick.test.sh gained a TTL self-heal scenario (crashed-spawn
recovery) and a real concurrent-process scenario proving `flock` serializes
two overlapping ticks; scenario 7's step-passthrough assertions now check a
BODY line from each fake fixture (not just the header banner), so silently
swallowing loop-census.sh's or pr-feedback.sh's output fails loudly. Added
loop-census.test.sh: runs the REAL loop-census.sh against a real git repo
with local/remote-tracking branches to directly cover in_flight detection,
including the "issue-4 vs issue-42" prefix-collision guard and the
"remotes/origin/" stripping/suffix-match on both branch-exists paths. Every
new/changed assertion was mutation-checked (reverted to the buggy behavior)
to confirm it fails loudly; all failure modes reproduced deterministically.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0135JVnX98DjPxTRzQZRy82q
@robercano-ghbot
robercano-ghbot merged commit ff8a7f0 into main Jul 8, 2026
9 checks passed
@robercano-ghbot
robercano-ghbot deleted the feat/issue-81-harden-loop-tick branch July 8, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden PR-loop tick: deterministic loop-tick.sh with single verdict, in-flight detection, spawn lock

2 participants