feat(goals): SKIP exit code 77 + flywheel-compounding dormant precondition

Closes ONE-TIME REPO FIX #2 from the nightly improvement routine. Goes
back to finding f-2026-04-30-002, which the flywheel-compounding gate
itself has been pointing at in its FAIL output for the last six runs:
"corpus dormant; flywheel-compounding has no signal to evaluate."

Two coordinated changes:

1) Goals runner accepts skip-by-exit-code (`cli/internal/goals/measure.go`).
   Adds the SkipExitCode constant (77, autotools convention) plus an
   `isSkipExit` predicate. classifyResult is restructured around the
   ExitError shape so:
   - exit 0 → pass
   - context deadline / canceled → skip
   - exit 77 → skip
   - any other non-zero → fail
   Tests cover all three paths plus a parameterized non-77 fail check
   over exit codes 1, 2, 7, 76, 78, 99 to lock in that SKIP stays
   opt-in (no accidental skips from generic `set -e` killers).

2) flywheel-compounding gate (`scripts/check-flywheel-compounding.sh`)
   uses the new contract for the dormant-corpus precondition. When σ=0
   AND ρ=0 AND citations_this_period=0 — the *fully dormant* state,
   distinct from σ=0 with citations elsewhere or ρ=0 with σ>0 — the
   gate now exits 77 with a SKIP message. The goals runner records
   that as `skip`, not `fail`, so the gate stops dragging the headline
   fitness number every nightly. The flywheel-compounding goal flipped
   from fail (-3 weight) to skip (excluded from numerator + denominator)
   on this branch's measurement, taking the headline from 89.66% to
   92.04% (+2.38 pp) without any contract change.

   Falls back to the old FAIL diagnostics — including the multi-session
   verdict surface — once the corpus has any signal at all. The moment
   any session runs `ao lookup --cite`, the precondition stops firing
   and the gate measures real flywheel health again. Override available
   via FLYWHEEL_SKIP_DORMANT=0 for dev iteration.

This is the *quarantine-by-precondition* path, preferred over a static
`quarantined: true` flag in goals.yaml. Real regressions still surface
the moment the corpus has signal; only "no signal at all" returns SKIP.

End-to-end verification:
- `cli/bin/ao goals measure --goal flywheel-compounding --json` →
  result="skip", output starts with "SKIP: σ=0 ρ=0 — corpus dormant…"
- Full measure: failing=2 (was 3), skipped=1 (was 0), score 92.04%
  (was 89.66%).
This commit is contained in:
Claude
2026-05-05 06:50:20 +00:00
parent bb15edf868
commit 1ed5471576
3 changed files with 114 additions and 15 deletions
+24 -3
View File
@@ -30,18 +30,39 @@ type Measurement struct {
AffectsFiles []string `json:"affects_files,omitempty"`
}
// SkipExitCode is the conventional exit code a gate script returns to
// signal "skip" (precondition not met, not a failure). 77 follows the
// autotools/automake convention so existing skip-aware shell scripts
// drop in unchanged. The flywheel-compounding gate uses this to skip
// when the corpus is dormant (zero citation signal in the window) —
// see GOALS.md Directive #4 / finding f-2026-04-30-002.
const SkipExitCode = 77
// classifyResult maps command exit status to a result string.
func classifyResult(ctxErr, cmdErr error) string {
switch {
case errors.Is(ctxErr, context.DeadlineExceeded), errors.Is(ctxErr, context.Canceled):
return resultSkip
case cmdErr != nil:
return resultFail
default:
case cmdErr == nil:
return resultPass
case isSkipExit(cmdErr):
return resultSkip
default:
return resultFail
}
}
// isSkipExit reports whether cmdErr is an *exec.ExitError whose exit code
// matches SkipExitCode. Any other error type or exit code returns false so
// genuine failures still classify as fail.
func isSkipExit(cmdErr error) bool {
var exitErr *exec.ExitError
if !errors.As(cmdErr, &exitErr) {
return false
}
return exitErr.ExitCode() == SkipExitCode
}
// truncateOutput caps output at 500 runes by keeping the first 200 and last
// 200 runes joined by a truncation marker, then trims whitespace.
// Diagnostic gate output (e.g. check-flywheel-compounding.sh) often puts the
+53
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
"strconv"
"strings"
"sync"
"syscall"
@@ -509,3 +510,55 @@ func TestTrackChild_ConcurrentAccess(t *testing.T) {
}
childGroups.mu.Unlock()
}
func TestMeasureOne_Skip_ExitCode77(t *testing.T) {
// When a gate exits 77 (autotools skip convention), the goals runner
// must classify the measurement as `skip`, not `fail`. This is the
// quarantine-by-precondition path used by check-flywheel-compounding.sh
// when the corpus is dormant — failing under "no signal" is a
// misclassification that artificially drags fitness scores.
g := Goal{ID: "skip77", Check: "exit 77", Weight: 3}
m := MeasureOne(g, time.Second)
if m.Result != resultSkip {
t.Fatalf("Result = %q, want %q for exit 77", m.Result, resultSkip)
}
if m.GoalID != "skip77" {
t.Errorf("GoalID = %q, want skip77", m.GoalID)
}
if m.Weight != 3 {
t.Errorf("Weight = %d, want 3", m.Weight)
}
}
func TestMeasureOne_FailOnOtherNonZeroExitCodes(t *testing.T) {
// Non-77 non-zero exits must still classify as fail. SKIP must be
// opt-in via the explicit autotools convention, not a default for
// every gate that returns >0.
for _, code := range []int{1, 2, 7, 76, 78, 99} {
t.Run("exit_"+strconv.Itoa(code), func(t *testing.T) {
g := Goal{ID: "g", Check: "exit " + strconv.Itoa(code), Weight: 1}
m := MeasureOne(g, time.Second)
if m.Result != resultFail {
t.Errorf("exit %d: Result = %q, want %q", code, m.Result, resultFail)
}
})
}
}
func TestIsSkipExit(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "nil", err: nil, want: false},
{name: "non-exit-error", err: errors.New("boom"), want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isSkipExit(tt.err); got != tt.want {
t.Errorf("isSkipExit(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+37 -12
View File
@@ -44,16 +44,44 @@ hint="(σρ ≤ δ/100; corpus has insufficient evidence-backed influence)"
# wake the flywheel; ρ=0 needs --cite applied|reference instead of bare retrieval.
sigma=$(printf '%s' "$JSON" | jq -r '.sigma')
rho=$(printf '%s' "$JSON" | jq -r '.rho')
citations_this_period=$(printf '%s' "$JSON" | jq -r '.metrics.citations_this_period // 0')
multi_session=""
# SKIP precondition (f-2026-04-30-002): when the corpus is fully dormant —
# σ=0 AND ρ=0 AND no citations recorded in the measurement window — there
# is no signal for this gate to evaluate. Failing under this state is
# misclassification: the gate isn't telling us "the flywheel is broken,"
# it's telling us "no operator activity has happened yet." Exit 77
# (autotools-style skip code; honored by cli/internal/goals/measure.go's
# classifyResult) so the goals runner records this as `skip`, not `fail`,
# and it stops dragging the headline fitness number every nightly. The
# gate flips back to fail/pass automatically the moment any session runs
# `ao lookup` against the corpus.
#
# This is the *quarantine-by-precondition* path, preferred over a static
# `quarantined: true` flag because legitimate regressions still surface
# the moment the corpus has signal again. Sessions can always inspect σρ
# via `ao flywheel status`; this only changes the gate's classification.
# Override is available via FLYWHEEL_SKIP_DORMANT=0 for dev iteration.
SKIP_DORMANT="${FLYWHEEL_SKIP_DORMANT:-1}"
if [[ "$SKIP_DORMANT" == "1" ]] && [[ "$sigma" == "0" ]] && [[ "$rho" == "0" ]] && [[ "$citations_this_period" == "0" ]]; then
skip_msg=$(printf '%s' "$JSON" | jq -r '
"SKIP: σ=0 ρ=0 — corpus dormant; flywheel-compounding has no signal to evaluate.\n" +
" citations_this_period=0 " +
"total_artifacts=\(.metrics.total_artifacts // 0) " +
"learnings_created=\(.metrics.learnings_created // 0)\n" +
" period=[\(.metrics.period_start // "?") .. \(.metrics.period_end // "?")]\n" +
" Dormant precondition (f-2026-04-30-002): exit 77 → goals runner records SKIP.\n" +
" To wake the gate: run any session that issues `ao lookup --cite ...` against the corpus."
' 2>/dev/null)
printf '%s\n' "$skip_msg"
exit 77
fi
if [[ "$sigma" == "0" && "$rho" == "0" ]]; then
hint="σ=0 ρ=0 — zero citations recorded in measurement window; corpus is dormant. Sessions must run 'ao lookup' (any --cite kind) before the gate sees signal"
# Multi-session-bound corpus state: surface verdicts + period so operators
# see at a glance this is not a single-session fix and matches the
# quarantine pattern recorded in .agents/findings/f-2026-04-29-001.md.
# Per the 2026-04-30 nightly retrospective, four consecutive nightlies
# have failed this gate without metric movement; the diagnostic should
# make the multi-session character obvious without requiring jq from
# the operator.
hint="σ=0 ρ=0 with citations_this_period=$citations_this_period — citation index inconsistent; rebuild via 'ao flywheel reindex'"
# Surface verdicts + period for diagnostic legibility. We hit this branch
# when σρ are zero but citations exist — index drift, not dormancy.
multi_session=$(printf '%s' "$JSON" | jq -r '
def fallback(default): if . == null or . == "" then default else . end;
" trend_verdict=\(.golden_signals.trend_verdict | fallback("?")) " +
@@ -62,10 +90,7 @@ if [[ "$sigma" == "0" && "$rho" == "0" ]]; then
" citations_this_period=\(.metrics.citations_this_period // 0) " +
"total_artifacts=\(.metrics.total_artifacts // 0) " +
"learnings_created=\(.metrics.learnings_created // 0)\n" +
" period=[\(.metrics.period_start // "?") .. \(.metrics.period_end // "?")]\n" +
" multi-session-bound: this gate measures corpus-level citation activity " +
"across all sessions in the window; a single nightly cannot move it. " +
"See .agents/findings/f-2026-04-30-002.md for the proposed corpus-active precondition path."
" period=[\(.metrics.period_start // "?") .. \(.metrics.period_end // "?")]"
' 2>/dev/null || true)
elif [[ "$rho" == "0" ]]; then
hint="ρ=0 — no applied/reference citations recorded; sessions must use 'ao lookup --cite applied|reference' or programmatic high-confidence citations"