newest="$(ls -1dt "$VERDICTSROOT"// 2>/dev/null | head -n1 || true)"
gitreins judge builds a per-stage EvalCap from the pipeline config. When a tier2 stage declares only max_iterations, the token caps stay at their unset sentinel -1. The compaction decision was computed as:
threshold := cap.TotalTokens - usedTokens
return usedTokens > threshold // TotalTokens == -1 → threshold is always negative → always true
With TotalTokens = -1, the threshold is -1 - used, so used > threshold is true even for an empty conversation. Compaction fires at the start of every iteration, bounded by the hard cap of 3 (maxCompactions), after which the judge burns its budget and stalls instead of producing a verdict. On PG-heavy repos tier1 already consumes ~300s, so the foreground MCP call hangs entirely.
compaction.go)Two-part fix: (1) repair unset caps at config-load time, (2) make the compaction decision refuse to fire on an unset cap, and (3) fail fast if a real compaction loop still occurs.
// 1) Config-load repair: a stage that sets only MaxIterations gets sane
// EvalCap defaults instead of -1 sentinels.
var DefaultCap = TokenCap{InputTokens: 128_000, OutputTokens: 8_000, TotalTokens: 136_000}
func NormalizeStage(cfg StageConfig, defaults TokenCap) StageConfig {
if cfg.Cap.TotalTokens <= 0 { cfg.Cap.TotalTokens = defaults.TotalTokens }
if cfg.Cap.InputTokens <= 0 { cfg.Cap.InputTokens = defaults.InputTokens }
if cfg.Cap.OutputTokens <= 0 { cfg.Cap.OutputTokens = defaults.OutputTokens }
return cfg
}
// 2) Fixed decision: an unset cap (<= 0) means "no compaction pressure
// configured" — never derive a negative threshold. With a positive cap,
// compact only once used tokens reach 80% of the budget.
func ShouldCompact(cap TokenCap, usedTokens int64) bool {
if cap.TotalTokens <= 0 { return false } // fixes the negative-threshold loop
threshold := int64(float64(cap.TotalTokens) * 0.80)
return usedTokens >= threshold
}
// 3) The judge loop stays bounded (maxCompactions = 3) and now fails fast
// with a diagnosable error instead of stalling.
func RunStage(ctx context.Context, cfg StageConfig,
usedTokens func() int64, compact, runIteration func(context.Context) error) (int, error) {
compactions := 0
for i := 0; i < cfg.MaxIterations; i++ {
if ShouldCompact(cfg.Cap, usedTokens()) {
if compactions >= maxCompactions {
return compactions, fmt.Errorf("%w: stage=%s cap=%+v compactions=%d",
ErrCompactionLoop, cfg.Name, cfg.Cap, compactions)
}
if err := compact(ctx); err != nil { return compactions, err }
compactions++
}
if err := runIteration(ctx); err != nil { return compactions, err }
}
return compactions, nil
}
Wire it in where the tier2 stage is loaded: cfg = NormalizeStage(cfg, DefaultCap) and route all compaction checks through ShouldCompact. No behavioral change for correctly-configured stages (still compacts at 80% of a real budget).
run-judge-safe.sh — background the judge under timeout 900, then poll for the newest verdict dir and only proceed once verdict.json carries a final status:
timeout "${GITREINS_JUDGE_TIMEOUT:-900}" gitreins judge "$ID" >"$LOG" 2>&1 &
JUDGE_PID=$!
# wait for the newest verdict dir (judge writes per-stage subdirs)
while :; do
newest="$(ls -1dt "$VERDICTS_ROOT"/*/ 2>/dev/null | head -n1 || true)"
if [[ -n "$newest" && "$newest" != "$before" && -f "${newest}verdict.json" ]] \
&& grep -qE '"status"[[:space:]]*:[[:space:]]*"(PASSED|FAILED|SKIPPED)"' "${newest}verdict.json"; then
echo "OK newest verdict: ${newest}verdict.json"; break
fi
kill -0 "$JUDGE_PID" 2>/dev/null || { echo "FAIL judge exited with no verdict" >&2; exit 1; }
sleep 5
done
For MCP: call this wrapper (or timeout 900 gitreins judge <id> itself) instead of invoking gitreins judge synchronously — the hard timeout means the tool call can never hang on the tier1 300s + compaction stall; the caller re-checks ls -1dt verdicts/*/ | head -1 for the fresh verdict dir.
I reproduced the exact mechanism in a runnable Go module (`~/gitreins-fix`, Go 1.26, offline, no deps) — full source in `compaction.go` / `compaction_test.go` / `cmd/repro/main.go` / `run-judge-safe.sh`.
**Root cause reproduced (`go run ./cmd/repro -buggy`):**
```
stage=tier2 max_iterations=5 evalcap={InputTokens:-1 OutputTokens:-1 TotalTokens:-1}
iter 1: used=100 threshold=-101 -> compact=true
iter 2: used=200 threshold=-201 -> compact=true
iter 3: used=300 threshold=-301 -> compact=true
iter 4: used=400 threshold=-401 -> compact=true
iter 5: used=500 threshold=-501 -> compact=true
=> negative threshold; compaction fires on every iteration
```
**Fix verified (`go run ./cmd/repro`):** after `NormalizeStage`, caps are `{128000, 8000, 136000}` and `compact=false` on all 5 iterations — the stage completes with 0 compactions.
**Go test suite — 8/8 PASS:**
| Test | Covers |
|---|---|
| `TestBuggyThresholdNegative` | proves pre-fix threshold is negative, fires on empty conversation |
| `TestBuggyFiresEveryIteration` | proves pre-fix fires 5/5 iterations |
| `TestUnsetCapNeverCompacts` | core fix: `used ∈ {0,1,1k,1M}` with unset cap → never compacts |
| `TestRunStageTier2Completes` | tier2-only-max_iterations config runs to completion, 0 compactions |
| `TestRunStageNormalizedCapCompactsOnce` | realistic post-compaction usage drop → exactly 1 compaction, 5 iterations |
| `TestRunStageCompactionLoopDetected` | positive cap + compaction that never reduces usage → `ErrCompactionLoop` at 3, no infinite spin |
| `TestNormalizeStageFillsUnsetCaps` | config-load repair fills all three caps |
| `TestThresholdBoundary` | compacts at exactly 80% (108_800/136_000), not before |
**Wrapper verified end-to-end with a stub `gitreins`:**
- *Success path:* stub writes tier1/tier2 dirs + `verdict.json` → wrapper detects newest verdict dir, prints verified JSON, exit 0.
- *Stall path:* stub sleeps 30s, `GITREINS_JUDGE_TIMEOUT=2` → judge killed by `timeout` at 2s, wrapper exits 1 with "FAIL judge exited with no verdict", 0 verdicts written, **no hang** (0.017s wall time).
- Edge case fixed during testing: my first `before` snapshot glob was wrong (`"$ROOT/"` vs `"$ROOT"/*/`), so a stale verdict could be accepted — corrected and both paths re-run on fresh roots.
---{"model": "deepseek-v4-flash", "problem_class": "go-gitreins-judge-compaction-loop", "result": "passed", "tests": 8}