Author SHA1 Message Date
steveandClaude Opus 5 14f8533e38 fix(ci): scrub the registry credential before running repo code
Build & push image / build-and-push (pull_request) Successful in 4s
Build & push image / test (pull_request) Successful in 9m47s
Both Claude reviewers caught this independently, and they are right. The test
job I added wrote a PUSH-CAPABLE REGISTRY_PASSWORD into a plaintext
~/.gitconfig and then ran `go build`/`go vet`/`go test` — repository code — on
pull_request events. This repo is public, so a fork PR could ship a test whose
only job is to print that file. The image build had already answered this
question correctly: its credentials are BuildKit secrets scoped to the
module-download RUN and are never present while code executes. I bolted on a
job that skipped the boundary its neighbour maintains.

Dependencies are now fetched in their own step which deletes ~/.gitconfig
before anything else runs, and asserts the scrub — against the whole home
directory, not against the file it just removed, because the credential can
also land in ~/.netrc or ~/.config/go/env. Verified the assertion is not
vacuous: planting the secret in ~/.netrc trips it. Later steps run with
GOPROXY=off, so any attempt to reach the network fails loudly rather than
quietly hunting for the credential that is now gone.

Also from round 4: TestEndpointProviderNamesAreAllAccepted pinned only
endpointProvider, while the constant is the error text for BOTH resolution
paths — it now asserts each advertised name resolves either way (break-checked
by dropping the gemini alias from resolveModel alone). preflight.sh documents
that ollama-cloud is checked on OLLAMA_API_KEY but hinted as
OLLAMA_CLOUD_API_KEY because run.sh copies one to the other first, an ordering
dependency that was invisible from the file.

And the comments that narrated this PR's own edit history ("the first version
of this change...") are rewritten as invariants. That history stops being true
the moment this merges, and the repo's doc policy says as much.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 17:28:08 -04:00
steveandClaude Opus 5 ebfaeba07e docs(qwen): warn that Model Studio keys are endpoint-scoped
A workspace-scoped Qwen endpoint rejects a key issued for the shared
international host with a genuine 'Incorrect API key provided', so a valid key
reads as invalid and the obvious next move — checking the key — confirms it is
fine and leads nowhere. Watched this cost real debugging time on a live
deployment today; gadfly would hit it identically. Documents the
GADFLY_ENDPOINT_* form, which reaches a workspace host with no code change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 17:22:08 -04:00
steveandClaude Opus 5 67a73616e1 fix(qwen): gadfly round 3 — stop guarding a duplicate, delete it
Build & push image / build-and-push (pull_request) Successful in 5s
Build & push image / test (pull_request) Canceled after 7m27s
Twelve findings, all real, and the two that matter are about the pre-flight I
added rather than about qwen.

The credential check had a false pass in the OTHER direction from round 2's: on
the GADFLY_BASE_URL override path, resolveModel builds the client with
GADFLY_API_KEY and never reads QWEN_API_KEY/KIMI_API_KEY, so treating the
provider's own key as sufficient there let a doomed run proceed. Having now
been wrong about these rules in both directions, the check no longer tries to
model both paths: it covers the REGISTRY path, whose rules it can state
exactly, and says nothing about the override path — which is hand-configured by
definition, while the registry path is the one you hit by adding a model id to
a var and forgetting the secret.

The logic moves to scripts/preflight.sh, sourced by both run.sh and the test.
The previous answer to "this test duplicates production logic" was a regex
drift-guard, and that guard compared only the provider table — not the decision
logic, which is precisely the half that carried the bug. A duplicate you guard
is still a duplicate; this deletes it, and the test now runs under `set -u`
like production does.

Also: the test that pins the shared provider slice held its own copy of the
list (now ranges the slice); endpointProviderNames had nothing tying it to the
switches it describes, which is how it shipped without "gemini" (a new test
asserts every advertised name resolves); two godoc lists had drifted; and the
"sanity" line that asserted nothing is gone.

And the repo had NO test job — `go test` and the pre-flight table both existed
and neither was ever executed by CI, which reads as coverage while providing
none. Added one (build/vet/gofmt/test/pre-flight), running alongside the image
build rather than gating it, so red is loud without standing between a push and
a rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 17:20:44 -04:00
steveandClaude Opus 5 1d6eaa08c5 fix(qwen): gadfly round 2 — the anti-drift list had already drifted
Build & push image / build-and-push (pull_request) Successful in 3s
Eight findings, all real, and the sharpest ones are about this PR's own fixes.

GADFLY_API_KEY was treated as a universal substitute in the pre-flight. It is
not: resolveModel reads it only AFTER the `baseURL == ""` early return, so on
the registry path — the documented primary path — a qwen/kimi built-in reads
its own variable and GADFLY_API_KEY is never consulted. A mis-set
GADFLY_API_KEY therefore passed pre-flight and 401'd five times anyway, which
is precisely the failure this check exists to prevent. It now only substitutes
when GADFLY_BASE_URL is also set.

`openai-compatible` was missing from the pre-flight table while both switches
accept it as an OPENAI_API_KEY alias, so that one spelling still fell through
to the cryptic five-failure mode.

endpointProviderNames — the constant I introduced *to stop* the two error
messages drifting — omitted the `gemini` alias both switches accept. It now
lists every accepted spelling.

And the case list itself was still duplicated across both switches plus the
test that pins them: three copies of the thing whose duplication started this.
Both switches now call isOpenAICompatProvider over one shared slice, and
endpointProvider's doc comment points at endpointProviderNames instead of
carrying a fourth hand-written copy.

scripts/preflight_test.sh moves into the repo (20 cases, up from 17, covering
openai-compatible and both GADFLY_API_KEY directions). It carries a drift guard
that diffs its copy of the provider table against run.sh's and aborts if they
differ — break-checked by deleting an arm from run.sh, which fails it loudly.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 16:59:01 -04:00
steveandClaude Opus 5 2367e696b5 fix(qwen): gadfly round 1 — three real findings, all sibling drift
Build & push image / build-and-push (pull_request) Successful in 3s
The pre-flight comment was the worst of them, and three models agreed. It said
providers absent from the table "need no key or carry it in their endpoint/DSN"
— false for google, which needs a key and is absent for an entirely different
reason: it accepts GOOGLE_API_KEY *or* GEMINI_API_KEY, so a single-variable arm
would silently skip a correctly-configured reviewer. That reasoning was in the
PR description and not in the code, so the comment invited exactly the wrong
edit. It now states both exclusion reasons and names google's.

Forwarded KIMI_API_KEY alongside QWEN_API_KEY in the dogfooding stub. This PR
argues that sibling call sites must move together, and I declared both secrets
in the reusable workflow and forwarded one — a config that looks complete and
401s on the model you didn't wire.

The two endpoint-provider error messages listed the same accepted set in
different order and spelling. Both functions accept an identical set, so they
now share one endpointProviderNames constant and cannot disagree.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 16:43:00 -04:00
steveandClaude Opus 5 0f40b21d79 feat(qwen): let Qwen (and Kimi) join the swarm
Build & push image / build-and-push (pull_request) Successful in 14s
Gadfly review (reusable) / review (pull_request) Successful in 8m46s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m46s
majordomo now ships qwen and kimi as built-ins that ARE the openai client at
their own base URL, so "qwen/qwen3.8-max" works as a GADFLY_MODELS entry once
the key reaches the container. This wires up the parts that key has to pass
through.

Two provider switches had to learn the names, not one. resolveModel's
GADFLY_BASE_URL override was the obvious one; endpointProvider's
GADFLY_ENDPOINT_* parser is its sibling, and I fixed the first and missed the
second on the first pass — a config that resolves one way and errors the other
for no reason a user could guess. TestOpenAICompatProvidersResolveOnBothPaths
now asserts both from one table so the pair fails together; break-checked in
both directions.

QWEN_API_KEY (and KIMI_API_KEY) are declared as workflow_call secrets and
forwarded to the container, with gadfly's own stub forwarding QWEN_API_KEY so a
qwen entry can join the default swarm by editing GADFLY_DEFAULT_MODELS alone —
no workflow edit, no re-release.

The run.sh credential pre-flight is now a provider→variable table instead of an
ollama-cloud special case. Without it a forgotten key surfaces as five
identical per-lens agent failures naming no variable, and the operator reads a
stack trace to find out which secret they missed. Google stays out of the table
on purpose: it accepts either GOOGLE_API_KEY or GEMINI_API_KEY, and a one-var
entry would wrongly skip a correctly-configured run. Verified across 17
provider x key-state combinations, including that a wrong-provider key never
satisfies qwen (majordomo refuses cross-provider fallback) and that unkeyed
providers are never blocked.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 16:30:29 -04:00
steveandClaude Opus 4.8 c9dab69d14 chore(reusable): pin the reviewer image to sha-b37cd09 [skip ci]
Adopt the provider-wide lens budget build (PR #27) for gadfly's own reviews
and for consumers pinning this reusable. No image rebuild needed — the tag
already exists — so skip CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 12:50:20 -04:00
steve b37cd09dc9 Merge pull request 'feat(concurrency): provider-wide lens budget, drop the model cap' (#27) from feat/provider-wide-lens-budget into main
Build & push image / build-and-push (push) Successful in 6s
2026-07-18 16:48:26 +00:00
steveandClaude Opus 4.8 6a74b64c7a fix(concurrency): address gadfly review — fail-open pool + doc drift
Build & push image / build-and-push (pull_request) Successful in 4s
Gadfly's own review of #27 surfaced a real robustness cluster (5 models,
error-handling) plus stale comments I missed.

Robustness — the flock permit pool could hang forever:
- tryAcquire swallowed os.OpenFile errors and treated every flock error as
  "busy", so a broken/missing pool dir (or a filesystem without flock) would
  spin-poll indefinitely; the fanout context is uncancellable and the per-lens
  timeout only starts AFTER acquire returns. Can't trigger in the deploy
  (entrypoint mkdir -p's the dir) but fixed defensively.
- tryAcquire now returns a structural error, distinguished from a healthy-full
  pool (EWOULDBLOCK = busy → keep polling). acquire FAILS OPEN on a structural
  error: logs once and runs the lens unthrottled rather than hanging the review.
- acquire uses time.NewTimer + Stop() (no per-poll timer leak on cancellation).
- activeLensSem warns on stderr when GADFLY_LENS_SEM_DIR is set but the size is
  invalid (was a silent degrade to unthrottled).
- New test: a broken pool dir fails open promptly.

Doc drift (stale references to the removed model cap):
- main.go defaultLensConcurrency + runSpecialists doc, entrypoint.sh status
  pre-seed + lane-launch comments, and the pre-existing lens_concurrency_test.go
  header all updated to the provider-wide-budget wording.

Accepted (graded real, not changed): all-models-start-at-once startup burst
(intended tradeoff) and index-0 sweep bias (cosmetic). One false positive
(one model using the whole budget is the intended lone-model behavior).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 12:47:36 -04:00
steveandClaude Opus 4.8 74831368ab feat(concurrency): provider-wide lens budget, drop the model cap
Build & push image / build-and-push (pull_request) Successful in 4s
Gadfly review (reusable) / review (pull_request) Successful in 14m47s
Adversarial Review (Gadfly) / review (pull_request) Successful in 14m47s
Concurrency was two multiplicative gates in two processes: entrypoint.sh
capped MODELS-at-once per provider (GADFLY_PROVIDER_CONCURRENCY) while each
model's binary separately capped its own lenses (GADFLY_LENS_CONCURRENCY).
A model therefore held its whole model-slot until its LAST lens finished,
stalling the next model even with idle lens capacity.

Collapse to one throttle: a provider-wide lens budget shared across all of
that provider's models. entrypoint now runs every model in a lane at once and
seeds a single cross-process permit pool per lane (a dir of N flock files,
sized by GADFLY_PROVIDER_LENS_CONCURRENCY -> GADFLY_LENS_CONCURRENCY). Each
lens pass (review+recheck) acquires a permit before it runs and releases it
after, so a model winding down immediately yields its freed permits to
another model's queued lenses. flock auto-releases on process death, so a
killed/crashed model can't leak budget.

- cmd/gadfly/lenssem.go: the flock permit pool (+ lenssem_test.go).
- main.go: runSpecialists holds a shared permit per lens; fanout sized to the
  budget so a lone model can use all of it. Falls back to the in-process limit
  when no pool is set (local runs, tests).
- entrypoint.sh: drop provider_cap/DEFAULT_CONC; run_lane runs all models and
  seeds the per-lane pool.
- GADFLY_PROVIDER_CONCURRENCY / GADFLY_CONCURRENCY are now ignored; the
  reusable workflow marks provider_concurrency deprecated and stops forwarding
  it. Docs (README, CLAUDE.md, examples) updated per the maintenance rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 12:22:11 -04:00
steveandClaude Opus 4.8 0d51879450 chore(reusable): bump reviewer image to sha-bb98fae (opencode-capable)
Build & push image / build-and-push (push) Successful in 5s
sha-bb98fae is the image built from the PR #26 merge, so it bundles the opencode
CLI and can run the new opencode/<model> engine. Consumers pinned to the gadfly
ref of this commit inherit it. Previous pin (sha-f468fe6) predated opencode.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 01:56:17 -04:00
steve bb98fae5f0 Merge pull request 'feat(engine): add opencode CLI review engine' (#26) from feat/opencode-engine into main
Build & push image / build-and-push (push) Successful in 41s
2026-07-18 05:51:51 +00:00
16 changed files with 821 additions and 137 deletions
+10
View File
@@ -46,6 +46,16 @@ jobs:
secrets: secrets:
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }} OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Forwarded so a "qwen/<model>" or "kimi/<model>" entry can join the
# swarm by editing the GADFLY_DEFAULT_MODELS var alone — no workflow
# edit, no re-release. Both are forwarded together on purpose: the
# reusable workflow declares both, and forwarding only one is a config
# that looks complete and 401s on the model you didn't wire. Empty until
# the repo secret exists, which is a 401 on that one model, not a broken
# review. NB kimi/<model> is Moonshot's own API — a different route than
# the kimi-k2.6:cloud swarm entry, which rides OLLAMA_CLOUD_API_KEY.
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }} GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }}
GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }} GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
with: with:
+55
View File
@@ -45,6 +45,61 @@ env:
IMAGE_NAME: gitea.stevedudenhoeffer.com/steve/gadfly IMAGE_NAME: gitea.stevedudenhoeffer.com/steve/gadfly
jobs: jobs:
# Runs alongside the image build rather than gating it: a red test should be
# loud on the PR without standing between Steve and a rebuild. Added because
# this repo had NO test job at all — `go test` and scripts/preflight_test.sh
# both existed and neither was ever executed by CI, which is worse than
# having no tests, since it reads as coverage.
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Fetch dependencies, then DESTROY the credential before any step that
# executes repository code. REGISTRY_PASSWORD is push-capable, this repo
# is public so pull_request runs can carry attacker-authored code, and
# `go test` runs that code — a plaintext ~/.gitconfig left in place is a
# credential any test could print. The image build faces the same
# question and answers it the same way: its creds are BuildKit secrets
# scoped to the module-download RUN, never present while code runs.
- name: Fetch private modules
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
go env -w GOPRIVATE=gitea.stevedudenhoeffer.com/*
git config --global url."https://${REGISTRY_USER}:${REGISTRY_PASSWORD}@gitea.stevedudenhoeffer.com/".insteadOf "https://gitea.stevedudenhoeffer.com/"
go mod download
rm -f "$HOME/.gitconfig"
# Prove the scrub worked, and prove it against the whole home dir —
# checking only the file just deleted would pass no matter what, and
# the credential can also reach ~/.netrc or ~/.config/go/env.
test ! -e "$HOME/.gitconfig"
if grep -rqF "${REGISTRY_PASSWORD}" "$HOME" 2>/dev/null; then
echo "::error::registry credential still present under \$HOME after scrub"
exit 1
fi
# GOPROXY=off from here on: the module cache is already warm, so any
# attempt to reach the network is a bug — and it fails loudly instead of
# quietly looking for the credential that is now gone.
- name: go build
env: { GOPROXY: "off" }
run: go build ./...
- name: go vet
env: { GOPROXY: "off" }
run: go vet ./...
- name: gofmt
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
- name: go test
env: { GOPROXY: "off" }
run: go test -count=1 ./...
- name: pre-flight credential table
run: bash scripts/preflight_test.sh
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
+27 -9
View File
@@ -44,7 +44,7 @@ on:
# #
# Owner-set user-scope variables (see README "Central config via variables"): # Owner-set user-scope variables (see README "Central config via variables"):
# GADFLY_DEFAULT_MODELS, GADFLY_DEFAULT_SPECIALISTS, # GADFLY_DEFAULT_MODELS, GADFLY_DEFAULT_SPECIALISTS,
# GADFLY_DEFAULT_PROVIDER_CONCURRENCY, GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY, # GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY (the provider-wide lens budget),
# GADFLY_ENDPOINT_NETHERSTORM (the local GPU box endpoint). # GADFLY_ENDPOINT_NETHERSTORM (the local GPU box endpoint).
# An unset variable + no input → the image default (one model, default suite), # An unset variable + no input → the image default (one model, default suite),
# so a public consumer with neither still gets a sane minimal review. # so a public consumer with neither still gets a sane minimal review.
@@ -53,8 +53,8 @@ on:
specialists: { type: string, default: "" } # GADFLY_SPECIALISTS — empty falls back to user var GADFLY_DEFAULT_SPECIALISTS specialists: { type: string, default: "" } # GADFLY_SPECIALISTS — empty falls back to user var GADFLY_DEFAULT_SPECIALISTS
provider: { type: string, default: "" } # GADFLY_PROVIDER provider: { type: string, default: "" } # GADFLY_PROVIDER
base_url: { type: string, default: "" } # GADFLY_BASE_URL base_url: { type: string, default: "" } # GADFLY_BASE_URL
provider_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_CONCURRENCY — empty falls back to user var GADFLY_DEFAULT_PROVIDER_CONCURRENCY provider_concurrency: { type: string, default: "" } # DEPRECATED / ignored — the per-provider MODEL cap was removed; the lens budget below is the single throttle. Kept so existing callers don't error.
provider_lens_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_LENS_CONCURRENCY — empty falls back to user var GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY provider_lens_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_LENS_CONCURRENCY — the per-provider lens budget (shared across the provider's models); empty falls back to user var GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY
timeout_secs: { type: string, default: "600" } # GADFLY_TIMEOUT_SECS (per lens) timeout_secs: { type: string, default: "600" } # GADFLY_TIMEOUT_SECS (per lens)
max_steps: { type: string, default: "14" } # GADFLY_MAX_STEPS max_steps: { type: string, default: "14" } # GADFLY_MAX_STEPS
worker_model: { type: string, default: "" } # GADFLY_WORKER_MODEL worker_model: { type: string, default: "" } # GADFLY_WORKER_MODEL
@@ -82,6 +82,15 @@ on:
OPENAI_API_KEY: { required: false } OPENAI_API_KEY: { required: false }
ANTHROPIC_API_KEY: { required: false } ANTHROPIC_API_KEY: { required: false }
GOOGLE_API_KEY: { required: false } GOOGLE_API_KEY: { required: false }
# Alibaba Model Studio (Qwen), for GADFLY_MODELS entries like
# "qwen/qwen3.8-max". NOT interchangeable with OPENAI_API_KEY: majordomo's
# qwen built-in reads QWEN_API_KEY only and deliberately refuses to fall
# back to the OpenAI key, so an unforwarded secret is a 401, not a
# mis-billed OpenAI call.
QWEN_API_KEY: { required: false }
# Moonshot (Kimi) over its own API — distinct from the ollama-cloud
# "kimi-k2.6:cloud" entry, which is keyed by OLLAMA_CLOUD_API_KEY.
KIMI_API_KEY: { required: false }
GADFLY_API_KEY: { required: false } GADFLY_API_KEY: { required: false }
CLAUDE_CODE_OAUTH_TOKEN: { required: false } CLAUDE_CODE_OAUTH_TOKEN: { required: false }
GADFLY_FINDINGS_URL: { required: false } GADFLY_FINDINGS_URL: { required: false }
@@ -99,11 +108,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout_minutes }} timeout-minutes: ${{ inputs.timeout_minutes }}
steps: steps:
# sha-3095ebf predated the Gitea >= 1.27 workflow_call reclassification # Pin the reviewer image to an immutable sha (act_runner caches :latest, so a
# (entrypoint.sh, added in 9d74cb9) — so the dogfood self-review skipped every # moved :latest is often NOT re-pulled). sha-b37cd09 adds the provider-wide
# PR with "event 'workflow_call' not handled". Bumped to sha-f468fe6 (current # lens budget (PR #27: one shared lens-permit pool per provider, the model cap
# main HEAD) which contains that fix. Bump per Gadfly release. # removed) on top of the opencode CLI engine (PR #26) and the Gitea >= 1.27
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-f468fe6 # workflow_call reclassification. Bump per Gadfly release.
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-b37cd09
env: env:
# --- event context (from the CALLER's github.*) ------------------- # --- event context (from the CALLER's github.*) -------------------
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
@@ -126,6 +136,12 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
# Qwen (Alibaba Model Studio) and Kimi (Moonshot) over their own APIs,
# for GADFLY_MODELS entries like "qwen/qwen3.8-max". Each built-in
# reads ONLY its own variable — no cross-provider fallback — so a
# missing line here is a clean 401, never a silently mis-keyed call.
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
GADFLY_API_KEY: ${{ secrets.GADFLY_API_KEY }} GADFLY_API_KEY: ${{ secrets.GADFLY_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Named LAN endpoints, defined in user/org vars (format # Named LAN endpoints, defined in user/org vars (format
@@ -151,7 +167,9 @@ jobs:
GADFLY_SPECIALISTS: ${{ inputs.specialists || vars.GADFLY_DEFAULT_SPECIALISTS }} GADFLY_SPECIALISTS: ${{ inputs.specialists || vars.GADFLY_DEFAULT_SPECIALISTS }}
GADFLY_PROVIDER: ${{ inputs.provider }} GADFLY_PROVIDER: ${{ inputs.provider }}
GADFLY_BASE_URL: ${{ inputs.base_url }} GADFLY_BASE_URL: ${{ inputs.base_url }}
GADFLY_PROVIDER_CONCURRENCY: ${{ inputs.provider_concurrency || vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY }} # NB: GADFLY_PROVIDER_CONCURRENCY (the old model cap) is intentionally no
# longer forwarded — entrypoint.sh ignores it. The lens budget is the one
# throttle now, shared across a provider's models.
GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ inputs.provider_lens_concurrency || vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY }} GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ inputs.provider_lens_concurrency || vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY }}
GADFLY_TIMEOUT_SECS: ${{ inputs.timeout_secs }} GADFLY_TIMEOUT_SECS: ${{ inputs.timeout_secs }}
GADFLY_MAX_STEPS: ${{ inputs.max_steps }} GADFLY_MAX_STEPS: ${{ inputs.max_steps }}
+14 -5
View File
@@ -57,7 +57,7 @@ Dockerfile multi-stage; private-module creds via BuildKit secrets ne
.gitea/workflows/build-image.yml push main → :latest; tag v* → :<tag>+:latest; PR → build-only .gitea/workflows/build-image.yml push main → :latest; tag v* → :<tag>+:latest; PR → build-only
.gitea/workflows/review-reusable.yml reusable (workflow_call) review job; resolves swarm config at .gitea/workflows/review-reusable.yml reusable (workflow_call) review job; resolves swarm config at
RUNTIME: consumer `with:` input → owner user-scope var (GADFLY_DEFAULT_MODELS / RUNTIME: consumer `with:` input → owner user-scope var (GADFLY_DEFAULT_MODELS /
_SPECIALISTS / _PROVIDER_CONCURRENCY / _PROVIDER_LENS_CONCURRENCY, + _SPECIALISTS / _PROVIDER_LENS_CONCURRENCY, +
GADFLY_ENDPOINT_RAGNAROS) → image default. Vars are injected per-run, so editing GADFLY_ENDPOINT_RAGNAROS) → image default. Vars are injected per-run, so editing
one var retunes the whole fleet even though long-lived act_runners CACHE this file one var retunes the whole fleet even though long-lived act_runners CACHE this file
by ref (a moved tag is NOT re-fetched — only a runtime value or a fresh @<sha> by ref (a moved tag is NOT re-fetched — only a runtime value or a fresh @<sha>
@@ -154,10 +154,19 @@ are actually exercised. OpenAI/Anthropic/Google come from majordomo's abstractio
NOT re-pulled, so the job silently runs the previous image. For a run that must use a specific NOT re-pulled, so the job silently runs the previous image. For a run that must use a specific
build (e.g. validating a just-pushed fix), pin the consumer stub to the immutable build (e.g. validating a just-pushed fix), pin the consumer stub to the immutable
`:sha-<short>` tag the build publishes, not `:latest`. `:sha-<short>` tag the build publishes, not `:latest`.
- **Concurrency is per-provider** (`entrypoint.sh`): each provider is a lane, lanes run in - **Concurrency is one per-provider lens budget** (`entrypoint.sh` + `cmd/gadfly/lenssem.go`):
parallel, `cap` (from `GADFLY_PROVIDER_CONCURRENCY` else `GADFLY_CONCURRENCY`, default 1) bounds each provider is a lane, lanes run in parallel, and within a lane ALL of the provider's models
models-at-once within a lane. The review timeout (`GADFLY_TIMEOUT_SECS`) is **per-lens**, not run at once — the only throttle is a **provider-wide lens budget** (max lens passes in flight,
shared across the suite — a slow model can't starve later lenses (the original timeout bug). a lens = one specialist's review+recheck). The budget comes from
`GADFLY_PROVIDER_LENS_CONCURRENCY` (`provider=N` map) else `GADFLY_LENS_CONCURRENCY` (default 1).
Because models are separate processes, the budget is a **cross-process permit pool**: entrypoint
seeds a per-lane dir of N flock files; each model's binary acquires one before a lens pass and
releases it after (flock auto-drops on process death). This replaced the old two-level
`GADFLY_PROVIDER_CONCURRENCY` MODEL cap × per-model `GADFLY_LENS_CONCURRENCY`, which multiplied
and let a model hold its slot through its last lens — stalling the next model with idle lens
capacity. Those two model-cap vars are now **ignored**. The review timeout
(`GADFLY_TIMEOUT_SECS`) is **per-lens**, not shared across the suite — a slow lens can't starve
the others (the original timeout bug).
- **Large-PR token burn**: the agent loop re-sends the whole transcript every step, so a giant - **Large-PR token burn**: the agent loop re-sends the whole transcript every step, so a giant
diff (the old `get_diff` dumped it untruncated, and it was embedded in both the review and diff (the old `get_diff` dumped it untruncated, and it was embedded in both the review and
recheck task) was re-transmitted ~steps × lenses × passes × models times — a ~250 K-token PR recheck task) was re-transmitted ~steps × lenses × passes × models times — a ~250 K-token PR
+50 -32
View File
@@ -73,11 +73,37 @@ majordomo failover chain / alias) is used verbatim.
| **[llama-swap](https://github.com/mostlygeek/llama-swap)** (model-swapping proxy) | `llama-swap`/`llama-swaps` (un-hyphenated `llamaswap`/`llamaswaps` also accepted) + `GADFLY_BASE_URL` or a `GADFLY_ENDPOINT_*` entry, or an `LLM_*` `llama-swap://` / `llama-swaps://` DSN | optional bearer | ⚠️ wired, **untested** | | **[llama-swap](https://github.com/mostlygeek/llama-swap)** (model-swapping proxy) | `llama-swap`/`llama-swaps` (un-hyphenated `llamaswap`/`llamaswaps` also accepted) + `GADFLY_BASE_URL` or a `GADFLY_ENDPOINT_*` entry, or an `LLM_*` `llama-swap://` / `llama-swaps://` DSN | optional bearer | ⚠️ wired, **untested** |
| **OpenAI-compatible** (incl. local Ollama's `/v1`) | `openai` + `GADFLY_BASE_URL` | `OPENAI_API_KEY` (any non-empty for Ollama) | ✅ tested against Ollama | | **OpenAI-compatible** (incl. local Ollama's `/v1`) | `openai` + `GADFLY_BASE_URL` | `OPENAI_API_KEY` (any non-empty for Ollama) | ✅ tested against Ollama |
| **OpenAI** | `openai` | `OPENAI_API_KEY` | ⚠️ wired, **untested** | | **OpenAI** | `openai` | `OPENAI_API_KEY` | ⚠️ wired, **untested** |
| **Qwen** (Alibaba Model Studio) | `qwen` | `QWEN_API_KEY` | ⚠️ wired, **untested** |
| **Kimi** (Moonshot) | `kimi` | `KIMI_API_KEY` | ⚠️ wired, **untested** |
| **Anthropic** | `anthropic` | `ANTHROPIC_API_KEY` | ⚠️ wired, **untested** | | **Anthropic** | `anthropic` | `ANTHROPIC_API_KEY` | ⚠️ wired, **untested** |
| **Google (Gemini)** | `google` | `GOOGLE_API_KEY` / `GEMINI_API_KEY` | ⚠️ wired, **untested** | | **Google (Gemini)** | `google` | `GOOGLE_API_KEY` / `GEMINI_API_KEY` | ⚠️ wired, **untested** |
Qwen and Kimi are majordomo built-ins that speak the OpenAI protocol at their own
endpoints, so `qwen/qwen3.8-max` or `kimi/kimi-k2-0711-preview` work as
`GADFLY_MODELS` entries with only the matching key set. Each reads **only** its own
variable — no cross-provider fallback — so forgetting to forward `QWEN_API_KEY`
gets you a skip notice naming it, not a mis-keyed call. Note `kimi/<model>` (Moonshot's
API, `KIMI_API_KEY`) is a different route than the `kimi-k2.6:cloud` entry in the
default swarm, which is Ollama Cloud and keyed by `OLLAMA_CLOUD_API_KEY`.
> **Qwen keys are endpoint-scoped, and the failure looks like a bad key.**
> Alibaba Model Studio issues *workspace-scoped* endpoints of the form
> `https://<workspace>.<region>.maas.aliyuncs.com/compatible-mode/v1`. A key
> issued for one host is rejected by another with a genuine
> `401 Incorrect API key provided` — so a perfectly good key reads as invalid if
> the endpoint doesn't match. The built-in defaults to the shared international
> host; point at your own with a named endpoint, which needs no code change:
>
> ```
> GADFLY_ENDPOINT_QWENWS = "qwen|https://<workspace>.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1|<key>"
> GADFLY_MODELS = "qwenws/qwen3.8-max,..."
> ```
>
> (Verified the hard way against a live deployment.)
> ### 🧪 Honest status > ### 🧪 Honest status
> Only the **Ollama** paths above are actually exercised. The OpenAI / Anthropic / Google > Only the **Ollama** paths above are actually exercised. The OpenAI / Qwen / Kimi /
> Anthropic / Google
> providers come "for free" from majordomo's abstraction and *should* work, but I haven't > providers come "for free" from majordomo's abstraction and *should* work, but I haven't
> spent money verifying them — treat them as untested. The OpenAI-**compatible** path **is** > spent money verifying them — treat them as untested. The OpenAI-**compatible** path **is**
> tested, because you can point it at a local Ollama (`GADFLY_BASE_URL=http://localhost:11434/v1`) > tested, because you can point it at a local Ollama (`GADFLY_BASE_URL=http://localhost:11434/v1`)
@@ -272,38 +298,32 @@ Unset = no delegation (current behavior).
### Concurrency (per-provider lanes) ### Concurrency (per-provider lanes)
With multiple models, each **provider** is its own lane and lanes run in **parallel**, so a fast With multiple models, each **provider** is its own lane and lanes run in **parallel**, so a fast
cloud provider isn't stuck behind a slow local box. Within a lane, at most `cap` models run at cloud provider isn't stuck behind a slow local box. There is **one throttle**: a per-provider
once — `cap` comes from `GADFLY_PROVIDER_CONCURRENCY` (a `provider=N` map) else `GADFLY_CONCURRENCY` **lens budget** — the max number of lens passes (a lens = one specialist's review+recheck) in
(default `1`). The timeout is **per-lens** (`GADFLY_TIMEOUT_SECS`), so a slow model on one lens flight at once for that provider. Every model in the lane runs concurrently and its lenses draw
can't starve the others. from that single shared budget, so nothing else caps how many models run. The budget comes from
`GADFLY_PROVIDER_LENS_CONCURRENCY` (a `provider=N` map) else the `GADFLY_LENS_CONCURRENCY` scalar
(default `1`). The timeout is **per-lens** (`GADFLY_TIMEOUT_SECS`), so a slow lens can't starve
the others.
```yaml ```yaml
# One local box (serial — it serves one model at a time) + 3 cloud reviews at once, # The local box gets 1 lens at a time (serial); the cloud lane runs up to 3 lens passes at once,
# both lanes running concurrently: # shared across ALL its models. Both lanes run concurrently.
GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=3,m1pro=1" GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1pro=1"
GADFLY_MODELS: "m1pro/qwen3:14b,qwen3-coder:480b-cloud,gpt-oss:120b-cloud" GADFLY_MODELS: "m1pro/qwen3:14b,qwen3-coder:480b-cloud,gpt-oss:120b-cloud"
``` ```
A model's provider is the spec's first segment (`m1pro/…``m1pro`), or `GADFLY_PROVIDER`/ A model's provider is the spec's first segment (`m1pro/…``m1pro`), or `GADFLY_PROVIDER`/
`ollama-cloud` for a bare id. Default (`cap 1`) keeps a single-provider pool fully sequential. `ollama-cloud` for a bare id. The budget is **shared across the provider's models**: with a
budget of 3 and two cloud models, you get 3 lens passes in flight in any mix — as one model
finishes a lens, the freed slot immediately goes to another model's next lens, so a model
winding down to its last lens never stalls the others (the pre-2026-07 design capped *models*
separately and did stall — that `GADFLY_PROVIDER_CONCURRENCY`/`GADFLY_CONCURRENCY` model cap is
**gone**; those vars are now ignored). Default (budget `1`) keeps a provider fully sequential.
**Lens fan-out (within a model).** By default the specialist lenses run **sequentially** inside > Under the hood the shared budget is a small cross-process permit pool (flock files, seeded per
each model (`GADFLY_LENS_CONCURRENCY=1`). Raise it to overlap the independent per-lens > lane by `entrypoint.sh`); permits release automatically if a model process dies, so a crashed
review+recheck passes — the model then posts its consolidated comment as soon as its lenses > lens can't leak budget.
finish (so with sequential models, results stream in per model and per-model timings stay
clean). Like the model cap, it's **per-provider configurable**: `GADFLY_PROVIDER_LENS_CONCURRENCY`
takes a `provider=N` map keyed by the **same provider lanes** as `GADFLY_PROVIDER_CONCURRENCY`,
falling back to the `GADFLY_LENS_CONCURRENCY` scalar (default `1`). **It multiplies with the
model cap:** total in-flight requests ≈ *models-at-once × lenses-at-once*, so to fan lenses out
without oversubscribing a backend, keep its model cap low and raise its lens cap:
```yaml
# Per provider: cloud runs one model at a time but fans its 3 lenses out (3 concurrent requests);
# the slow local box stays fully serial. Both provider lanes still run in parallel.
GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=1,m1=1"
GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1=1"
GADFLY_SPECIALISTS: "security,correctness,error-handling"
```
### Live status board ### Live status board
@@ -418,8 +438,7 @@ on its next review **without** a re-pin or a tag move:
|---|---| |---|---|
| `GADFLY_DEFAULT_MODELS` | `GADFLY_MODELS` (csv) | | `GADFLY_DEFAULT_MODELS` | `GADFLY_MODELS` (csv) |
| `GADFLY_DEFAULT_SPECIALISTS` | the lens suite | | `GADFLY_DEFAULT_SPECIALISTS` | the lens suite |
| `GADFLY_DEFAULT_PROVIDER_CONCURRENCY` | models-at-once per provider | | `GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY` | the per-provider lens budget (lens passes in flight per provider, shared across its models) |
| `GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY` | lenses-at-once per provider |
| `GADFLY_ENDPOINT_RAGNAROS` | a named endpoint, e.g. `llamaswap\|https://host` | | `GADFLY_ENDPOINT_RAGNAROS` | a named endpoint, e.g. `llamaswap\|https://host` |
Adding a *new* named endpoint still needs a one-line reusable edit (Gitea can't auto-expose arbitrary Adding a *new* named endpoint still needs a one-line reusable edit (Gitea can't auto-expose arbitrary
@@ -441,10 +460,9 @@ The reviewer binary reads these (the stub/entrypoint set sane defaults):
| `GADFLY_SELECTOR_MODEL` | review model | model that picks lenses in `auto` mode | | `GADFLY_SELECTOR_MODEL` | review model | model that picks lenses in `auto` mode |
| `GADFLY_WORKER_MODEL` | — | cheap model for `delegate_investigation`; unset = no delegation | | `GADFLY_WORKER_MODEL` | — | cheap model for `delegate_investigation`; unset = no delegation |
| `GADFLY_WORKER_MAX_STEPS` | 8 | tool-step cap for a delegated worker run | | `GADFLY_WORKER_MAX_STEPS` | 8 | tool-step cap for a delegated worker run |
| `GADFLY_CONCURRENCY` | 1 | default max models run at once **per provider** | | `GADFLY_LENS_CONCURRENCY` | 1 | **per-provider lens budget** — lens passes in flight per provider, shared across all its models (all a provider's models run at once; this is the only throttle) |
| `GADFLY_PROVIDER_CONCURRENCY` | — | per-provider overrides, e.g. `ollama-cloud=3,m1pro=1` | | `GADFLY_PROVIDER_LENS_CONCURRENCY` | — | per-provider lens-budget overrides, a `provider=N` map, e.g. `ollama-cloud=3,m1=1` |
| `GADFLY_LENS_CONCURRENCY` | 1 | specialist lenses run at once **within a model** (× model cap = total in-flight) | | `GADFLY_CONCURRENCY` / `GADFLY_PROVIDER_CONCURRENCY` | | **removed** (was the per-provider models-at-once cap; now ignored — the lens budget is the single throttle) |
| `GADFLY_PROVIDER_LENS_CONCURRENCY` | — | per-provider lens overrides, same lanes as `GADFLY_PROVIDER_CONCURRENCY`, e.g. `ollama-cloud=3,m1=1` |
| `GADFLY_MAX_STEPS` | 24 | review-pass tool-step cap | | `GADFLY_MAX_STEPS` | 24 | review-pass tool-step cap |
| `GADFLY_TIMEOUT_SECS` | 300 | deadline **per specialist lens** (review+recheck) | | `GADFLY_TIMEOUT_SECS` | 300 | deadline **per specialist lens** (review+recheck) |
| `GADFLY_RECHECK` | on | set `0`/`false` to skip the recheck pass | | `GADFLY_RECHECK` | on | set `0`/`false` to skip the recheck pass |
+1 -1
View File
@@ -156,7 +156,7 @@ func TestRunSpecialists_PerProviderFanOut(t *testing.T) {
// TestLensConcurrency covers the resolution matrix: scalar default, scalar // TestLensConcurrency covers the resolution matrix: scalar default, scalar
// override, and per-provider override keyed by the model's resolved lane (same // override, and per-provider override keyed by the model's resolved lane (same
// lane rule entrypoint.sh uses for GADFLY_PROVIDER_CONCURRENCY). // lane rule entrypoint.sh uses for GADFLY_PROVIDER_LENS_CONCURRENCY).
func TestLensConcurrency(t *testing.T) { func TestLensConcurrency(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
"time"
)
// lensSem is the PROVIDER-WIDE lens-permit pool. Historically each model's binary
// throttled its own lenses in-process (GADFLY_LENS_CONCURRENCY) while entrypoint.sh
// separately capped how many MODELS ran at once — two multiplicative gates in
// different processes. That let a model hold its whole slot until its last lens
// finished, stalling the next model even with idle lens capacity.
//
// Instead, entrypoint.sh now runs all of a provider's models concurrently and
// seeds ONE directory of N permit files per provider (N = the provider's lens
// budget). Every model process in that lane draws from the same pool: a lens pass
// (review + recheck) acquires a permit before it runs and releases it after, so a
// model winding down immediately yields its freed permits to another model's
// queued lenses. Permits are held with flock, which the kernel drops when the
// holding process exits — so a killed/crashed model frees its permits for free.
type lensSem struct {
dir string
size int
}
// lensSemPollInterval is how often a blocked acquirer re-sweeps the permit files.
// Lens passes run for many seconds to minutes, so a coarse poll adds negligible
// latency while keeping the mechanism a few lines of stdlib (no IPC primitives).
const lensSemPollInterval = 150 * time.Millisecond
// activeLensSem returns the shared semaphore configured by entrypoint.sh, or nil
// when it isn't set (standalone/local runs, tests, or an older entrypoint) — in
// which case runSpecialists falls back to the in-process fanout limit alone, i.e.
// the pre-existing per-model behavior.
func activeLensSem() *lensSem {
dir := os.Getenv("GADFLY_LENS_SEM_DIR")
if dir == "" {
return nil
}
n := envInt("GADFLY_LENS_SEM_SIZE", 0)
if n < 1 {
// A dir was requested but the size is missing/blank/invalid. Rather than
// silently run unthrottled, say so on stderr — it's almost certainly a
// misconfiguration in entrypoint.sh (the two are set together).
fmt.Fprintf(os.Stderr, "gadfly: GADFLY_LENS_SEM_DIR set but GADFLY_LENS_SEM_SIZE=%q invalid; lenses run unthrottled\n", os.Getenv("GADFLY_LENS_SEM_SIZE"))
return nil
}
return &lensSem{dir: dir, size: n}
}
// acquire blocks until a permit is free (or ctx is done) and returns a release
// func. It FAILS OPEN: if the pool directory is structurally unusable (missing,
// unwritable, or on a filesystem without flock) it logs once and returns a no-op
// release with a nil error, so the lens runs UNTHROTTLED rather than hanging the
// whole review forever — this is an advisory reviewer, a slightly oversubscribed
// backend beats a stuck one. Only a full-but-healthy pool actually blocks; on ctx
// cancellation it returns a no-op release plus ctx.Err() so the caller can surface
// the lens as "did not run" instead of leaking a permit.
func (s *lensSem) acquire(ctx context.Context) (func(), error) {
for {
release, ok, err := s.tryAcquire()
if ok {
return release, nil
}
if err != nil {
fmt.Fprintf(os.Stderr, "gadfly: lens permit pool %q unusable (%v); proceeding without a permit\n", s.dir, err)
return func() {}, nil
}
timer := time.NewTimer(lensSemPollInterval)
select {
case <-ctx.Done():
timer.Stop() // don't leak the timer when we bail on cancellation
return func() {}, ctx.Err()
case <-timer.C:
}
}
}
// tryAcquire makes one non-blocking sweep over the permit files. It returns a
// release func for the first one it locks; otherwise it distinguishes a healthy
// FULL pool (some permit was openable but flock-busy → ok=false, err=nil → keep
// polling) from a structurally BROKEN pool (no permit was even acquirable and none
// was merely busy → err set → caller fails open). Permit files are created lazily
// (entrypoint.sh only guarantees the directory exists). Closing the *os.File in
// the returned func drops the flock (the lock lives on the open file description).
func (s *lensSem) tryAcquire() (func(), bool, error) {
sawBusy := false
var structural error
for i := 0; i < s.size; i++ {
path := filepath.Join(s.dir, fmt.Sprintf("permit.%d", i))
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
structural = err // e.g. the pool dir is gone or unwritable
continue
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err == nil {
return func() { f.Close() }, true, nil
} else if errors.Is(err, syscall.EWOULDBLOCK) {
sawBusy = true // this permit is held by someone else — pool has capacity
f.Close()
} else {
structural = err // flock unsupported / other → not a transient "busy"
f.Close()
}
}
if sawBusy {
return nil, false, nil // full but healthy: another lens will free a permit
}
return nil, false, structural
}
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"context"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
// flock permits are per open-file-description, so two separate opens of the same
// permit file conflict even within one process — the exhaustion, blocking, and
// max-in-flight tests below therefore exercise the same semantics a real
// multi-process lane would see.
func TestActiveLensSemUnset(t *testing.T) {
t.Setenv("GADFLY_LENS_SEM_DIR", "")
if s := activeLensSem(); s != nil {
t.Fatalf("expected nil sem when GADFLY_LENS_SEM_DIR unset, got %+v", s)
}
// A dir with a zero/blank size is inert too (must not divide the pool by 0).
t.Setenv("GADFLY_LENS_SEM_DIR", t.TempDir())
t.Setenv("GADFLY_LENS_SEM_SIZE", "0")
if s := activeLensSem(); s != nil {
t.Fatalf("expected nil sem when size < 1, got %+v", s)
}
}
func TestLensSemExhaustionAndRelease(t *testing.T) {
s := &lensSem{dir: t.TempDir(), size: 2}
r1, ok, err := s.tryAcquire()
if !ok || err != nil {
t.Fatalf("first acquire should succeed: ok=%v err=%v", ok, err)
}
r2, ok, err := s.tryAcquire()
if !ok || err != nil {
t.Fatalf("second acquire should succeed: ok=%v err=%v", ok, err)
}
// Pool full but healthy: ok=false with NO error (keep polling), not a
// structural failure.
if _, ok, err := s.tryAcquire(); ok || err != nil {
t.Fatalf("third acquire should be full-but-healthy: ok=%v err=%v", ok, err)
}
r1() // free one permit
r3, ok, err := s.tryAcquire()
if !ok || err != nil {
t.Fatalf("acquire should succeed after a release: ok=%v err=%v", ok, err)
}
r2()
r3()
}
// A structurally broken pool (dir missing/unwritable) must FAIL OPEN — tryAcquire
// surfaces the error and acquire returns promptly with a no-op release and no
// error, so a lens runs unthrottled instead of spinning forever.
func TestLensSemBrokenPoolFailsOpen(t *testing.T) {
s := &lensSem{dir: filepath.Join(t.TempDir(), "does", "not", "exist"), size: 2}
if _, ok, err := s.tryAcquire(); ok || err == nil {
t.Fatalf("tryAcquire on a broken pool should report a structural error: ok=%v err=%v", ok, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
start := time.Now()
release, err := s.acquire(ctx)
if err != nil {
t.Fatalf("acquire should fail open (nil err), got %v", err)
}
if waited := time.Since(start); waited > time.Second {
t.Fatalf("acquire on a broken pool should return promptly, waited %v", waited)
}
release() // must be a safe no-op
}
func TestLensSemAcquireBlocksThenCancels(t *testing.T) {
s := &lensSem{dir: t.TempDir(), size: 1}
// Immediate success while a permit is free.
release, err := s.acquire(context.Background())
if err != nil {
t.Fatalf("acquire on a free pool: %v", err)
}
// With the only permit held, acquire must block until ctx expires.
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
if _, err := s.acquire(ctx); err == nil {
t.Fatal("acquire on an exhausted pool should return ctx error, not a permit")
}
if waited := time.Since(start); waited < 50*time.Millisecond {
t.Fatalf("acquire returned too fast (%v); it should have blocked on the full pool", waited)
}
release()
}
func TestLensSemNeverExceedsSize(t *testing.T) {
const size = 3
s := &lensSem{dir: t.TempDir(), size: size}
var inFlight, peak int64
var wg sync.WaitGroup
for i := 0; i < 12; i++ {
wg.Add(1)
go func() {
defer wg.Done()
release, err := s.acquire(context.Background())
if err != nil {
t.Errorf("acquire: %v", err)
return
}
defer release()
n := atomic.AddInt64(&inFlight, 1)
for {
p := atomic.LoadInt64(&peak)
if n <= p || atomic.CompareAndSwapInt64(&peak, p, n) {
break
}
}
time.Sleep(20 * time.Millisecond)
atomic.AddInt64(&inFlight, -1)
}()
}
wg.Wait()
if peak > size {
t.Fatalf("max in-flight lens permits = %d, exceeds budget %d", peak, size)
}
if peak == 0 {
t.Fatal("no permits were ever acquired")
}
}
+55 -31
View File
@@ -49,15 +49,18 @@
// GADFLY_RECHECK set to 0/false to skip the recheck pass (optional, default on). // GADFLY_RECHECK set to 0/false to skip the recheck pass (optional, default on).
// GADFLY_RECHECK_MAX_STEPS recheck-pass step cap (optional, default 16). // GADFLY_RECHECK_MAX_STEPS recheck-pass step cap (optional, default 16).
// GADFLY_TIMEOUT_SECS overall deadline in seconds, shared by both passes (optional, default 300). // GADFLY_TIMEOUT_SECS overall deadline in seconds, shared by both passes (optional, default 300).
// GADFLY_LENS_CONCURRENCY how many specialist lenses run concurrently within this // GADFLY_LENS_CONCURRENCY how many specialist lenses run concurrently (optional,
// model (optional, default 1 = sequential). Total in-flight // default 1 = sequential). Under entrypoint.sh this is the
// model requests ≈ this × entrypoint.sh's per-provider model // PROVIDER-WIDE lens budget, shared across all of that
// concurrency, so keep the product within the backend's budget. // provider's models via a permit pool (GADFLY_LENS_SEM_DIR),
// so it bounds total lens passes in flight per provider.
// GADFLY_PROVIDER_LENS_CONCURRENCY per-provider override for the above, as a // GADFLY_PROVIDER_LENS_CONCURRENCY per-provider override for the above, as a
// "provider=N,provider=N" map keyed by the SAME provider // "provider=N,provider=N" map keyed by the provider lanes
// lanes as GADFLY_PROVIDER_CONCURRENCY (e.g. // (e.g. "ollama-cloud=3,m1=1"). Wins over
// "ollama-cloud=3,m1=1"). Wins over GADFLY_LENS_CONCURRENCY // GADFLY_LENS_CONCURRENCY for the model's provider.
// for the model's provider; falls back to it otherwise. // GADFLY_LENS_SEM_DIR / GADFLY_LENS_SEM_SIZE set by entrypoint.sh: the shared
// cross-process lens-permit pool (dir of N flock files)
// and its size. Unset => in-process lensConcurrency only.
// GADFLY_MAX_DIFF_CHARS diff chars embedded in the review prompt (optional, default 60000; // GADFLY_MAX_DIFF_CHARS diff chars embedded in the review prompt (optional, default 60000;
// the full diff is reachable via the paginated get_diff tool). // the full diff is reachable via the paginated get_diff tool).
// //
@@ -94,9 +97,11 @@ const (
// calls and then hard-failing with "max steps reached without a final // calls and then hard-failing with "max steps reached without a final
// answer" — it always has a few steps left to wrap up. // answer" — it always has a few steps left to wrap up.
defaultWrapUpReserve = 4 defaultWrapUpReserve = 4
// defaultLensConcurrency is how many specialist lenses run at once within a // defaultLensConcurrency is the fallback lens budget when neither
// single model. 1 keeps the suite sequential (the historical behavior); // GADFLY_PROVIDER_LENS_CONCURRENCY nor GADFLY_LENS_CONCURRENCY is set. 1 keeps
// higher values overlap the independent per-lens passes. See runSpecialists. // the suite sequential (the historical behavior); higher values overlap the
// independent per-lens passes. Under entrypoint.sh the resolved value is the
// provider-wide budget shared across the provider's models. See runSpecialists.
defaultLensConcurrency = 1 defaultLensConcurrency = 1
) )
@@ -242,25 +247,34 @@ func run() error {
// runSpecialists reviews the diff through each lens and returns the results in // runSpecialists reviews the diff through each lens and returns the results in
// the SAME order as specialists, regardless of finish order. It uses executus's // the SAME order as specialists, regardless of finish order. It uses executus's
// fanout primitive: up to GADFLY_LENS_CONCURRENCY lenses run concurrently (the // fanout primitive to overlap independent lens passes (fanout.Run returns one
// default of 1 keeps the suite sequential, exactly as before), and fanout.Run // result per lens in input order); each lens runs under its own per-lens timeout
// returns one result per lens in input order. Each lens already runs under its // (reviewWithSpecialist) and the lenses only read the immutable repoFS.
// own per-lens timeout (reviewWithSpecialist) and the lenses only read the
// immutable repoFS, so concurrency simply overlaps independent passes.
// //
// Caution: this fans out WITHIN one model. It multiplies with entrypoint.sh's // Throttling: when entrypoint.sh runs several of a provider's models at once it
// per-provider model concurrency, so total concurrent backend requests ≈ // seeds a shared lens-permit pool (activeLensSem) that every model's lenses draw
// (models at once) × (lenses at once). To fan lenses out without oversubscribing // from, so the real cap is total lens passes in flight per PROVIDER — not
// the backend, run models one at a time (provider lane cap 1) and raise this. // (models at once) × (lenses at once). Absent that pool (local runs, tests) the
// in-process GADFLY_LENS_CONCURRENCY limit applies alone (default 1 = sequential).
func runSpecialists(eng reviewEngine, base string, specialists []Specialist, task, diff string) []specialistResult { func runSpecialists(eng reviewEngine, base string, specialists []Specialist, task, diff string) []specialistResult {
// Optional live status board: publishes this model's per-lens progress to a // Optional live status board: publishes this model's per-lens progress to a
// file the entrypoint board renders. Inert (no-op) unless GADFLY_STATUS_FILE // file the entrypoint board renders. Inert (no-op) unless GADFLY_STATUS_FILE
// is set, so plain runs are unaffected. // is set, so plain runs are unaffected.
sw := newStatusWriter(os.Getenv("GADFLY_MODEL"), modelProvider(), specialists) sw := newStatusWriter(os.Getenv("GADFLY_MODEL"), modelProvider(), specialists)
// The cross-process pool (if any) is the real ceiling; size the in-process
// fanout to it so a lone model in its lane can use the whole provider budget,
// while extra goroutines simply block in sem.acquire until a permit frees.
// Absent a pool, fall back to the in-process lens limit.
sem := activeLensSem()
maxConcurrent := lensConcurrency()
if sem != nil {
maxConcurrent = sem.size // the shared pool is the real ceiling
}
fanResults := fanout.Run(context.Background(), specialists, fanout.Options[Specialist]{ fanResults := fanout.Run(context.Background(), specialists, fanout.Options[Specialist]{
MaxConcurrent: lensConcurrency(), MaxConcurrent: maxConcurrent,
}, func(_ context.Context, sp Specialist) (res specialistResult, _ error) { }, func(ctx context.Context, sp Specialist) (res specialistResult, _ error) {
// A panic in one lens must not crash the whole binary (which would kill // A panic in one lens must not crash the whole binary (which would kill
// every other lens's output) or leave this lens stuck at "running" on the // every other lens's output) or leave this lens stuck at "running" on the
// status board. fanout does not recover fn panics, so we do it here: // status board. fanout does not recover fn panics, so we do it here:
@@ -271,6 +285,17 @@ func runSpecialists(eng reviewEngine, base string, specialists []Specialist, tas
sw.set(sp.Name, lensFinished, "", true) sw.set(sp.Name, lensFinished, "", true)
} }
}() }()
// Hold a provider-wide permit for this lens's whole review+recheck pass.
// While waiting the lens stays "queued" on the board; cancellation before
// a permit frees surfaces as a did-not-run lens rather than a leaked slot.
if sem != nil {
release, err := sem.acquire(ctx)
if err != nil {
sw.set(sp.Name, lensFinished, "", true)
return specialistResult{spec: sp, out: fmt.Sprintf("⚠️ This reviewer did not run: %v", err), verdict: verdictUnknown, errored: true}, nil
}
defer release()
}
sw.set(sp.Name, lensRunning, "", false) sw.set(sp.Name, lensRunning, "", false)
out, errored := reviewWithSpecialist(eng, base, sp, task, diff) out, errored := reviewWithSpecialist(eng, base, sp, task, diff)
v := parseVerdict(out) v := parseVerdict(out)
@@ -293,14 +318,13 @@ func runSpecialists(eng reviewEngine, base string, specialists []Specialist, tas
return results return results
} }
// lensConcurrency resolves how many specialist lenses run at once for THIS run's // lensConcurrency resolves the lens budget for THIS run's provider: a per-provider
// model. It mirrors entrypoint.sh's per-provider MODEL concurrency: a // override in GADFLY_PROVIDER_LENS_CONCURRENCY ("provider=N,...") wins for the
// per-provider override in GADFLY_PROVIDER_LENS_CONCURRENCY ("provider=N,...") // model's provider (resolved by modelProvider()), otherwise the
// wins for the model's provider, otherwise the GADFLY_LENS_CONCURRENCY scalar // GADFLY_LENS_CONCURRENCY scalar (default 1). Under entrypoint.sh the SAME value
// (default 1). The provider is resolved by modelProvider() — the SAME lane rule // seeds the shared cross-process permit pool (activeLensSem), so it is the
// entrypoint uses for GADFLY_PROVIDER_CONCURRENCY — so e.g. // provider-wide budget rather than a per-model one; standalone it caps the single
// "ollama-cloud=3,m1=1" fans cloud lenses out while keeping a slow local box // model's in-process fanout.
// serial, exactly the way the model map does for whole models.
func lensConcurrency() int { func lensConcurrency() int {
if n, ok := providerOverride("GADFLY_PROVIDER_LENS_CONCURRENCY", modelProvider()); ok { if n, ok := providerOverride("GADFLY_PROVIDER_LENS_CONCURRENCY", modelProvider()); ok {
return n return n
@@ -310,7 +334,7 @@ func lensConcurrency() int {
// providerOverride parses a "provider=N,provider=N" env map and returns the // providerOverride parses a "provider=N,provider=N" env map and returns the
// value for provider when present and valid (>0). Mirrors entrypoint.sh's // value for provider when present and valid (>0). Mirrors entrypoint.sh's
// provider_cap lookup so the two concurrency maps share one syntax. // provider_lens_cap lookup so the two share one syntax.
func providerOverride(envName, provider string) (int, bool) { func providerOverride(envName, provider string) (int, bool) {
for _, item := range strings.Split(os.Getenv(envName), ",") { for _, item := range strings.Split(os.Getenv(envName), ",") {
k, v, ok := strings.Cut(item, "=") k, v, ok := strings.Cut(item, "=")
+64 -18
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"slices"
"strings" "strings"
"gitea.stevedudenhoeffer.com/steve/majordomo" "gitea.stevedudenhoeffer.com/steve/majordomo"
@@ -19,6 +20,35 @@ import (
// model list is just ids like "qwen3-coder:480b-cloud" — working unchanged. // model list is just ids like "qwen3-coder:480b-cloud" — working unchanged.
const defaultProvider = "ollama-cloud" const defaultProvider = "ollama-cloud"
// openAICompatProviders are the provider names that resolve to the plain
// openai client at an explicit base URL. openai-compatible is the generic
// spelling; kimi (Moonshot) and qwen (Alibaba Model Studio) are majordomo
// built-ins that ARE that client pointed elsewhere, so an explicit endpoint for
// either belongs on the same branch.
//
// One slice, because three places must agree: resolveModel's endpoint
// override, endpointProvider's GADFLY_ENDPOINT_* parser, and the test that
// pins them. A name accepted by one and rejected by another is a config that
// works when written one way and errors the other, for no reason a user could
// guess.
var openAICompatProviders = []string{"openai", "openai-compatible", "kimi", "qwen"}
func isOpenAICompatProvider(name string) bool {
return slices.Contains(openAICompatProviders, name)
}
// endpointProviderNames is the operator-facing list of providers that accept an
// explicit endpoint. resolveModel and endpointProvider accept the SAME set, so
// one message serves both rather than each carrying a copy that drifts in
// order and spelling.
//
// Every accepted spelling belongs here, aliases included —
// TestEndpointProviderNamesAreAllAccepted asserts that each name listed
// actually resolves, so an omission fails the build rather than misleading an
// operator who is already debugging.
const endpointProviderNames = "openai/openai-compatible/kimi/qwen/ollama/ollama-cloud/" +
"llama-swap/llama-swaps/llamaswap/llamaswaps/foreman/anthropic/google/gemini"
// resolveModel builds the review model from the environment. Gadfly is powered // resolveModel builds the review model from the environment. Gadfly is powered
// by majordomo, so it can target any provider majordomo supports — Ollama // by majordomo, so it can target any provider majordomo supports — Ollama
// (local or cloud), OpenAI, Anthropic, Google, or any OpenAI/Ollama-compatible // (local or cloud), OpenAI, Anthropic, Google, or any OpenAI/Ollama-compatible
@@ -33,10 +63,12 @@ const defaultProvider = "ollama-cloud"
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible // GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
// servers, a remote Ollama, an OpenRouter-style gateway…). // servers, a remote Ollama, an OpenRouter-style gateway…).
// When set, the provider is constructed directly at that URL. // When set, the provider is constructed directly at that URL.
// GADFLY_API_KEY bearer/API key for the chosen provider. Optional; when // GADFLY_API_KEY bearer/API key for the chosen provider, used ONLY on the
// unset the provider falls back to its standard env var // GADFLY_BASE_URL override path. With no base URL the
// (OLLAMA_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / // provider reads its own standard variable and this is never
// GOOGLE_API_KEY|GEMINI_API_KEY). Local Ollama needs none. // consulted: OLLAMA_API_KEY / OPENAI_API_KEY /
// QWEN_API_KEY / KIMI_API_KEY / ANTHROPIC_API_KEY /
// GOOGLE_API_KEY|GEMINI_API_KEY. Local Ollama needs none.
// //
// With GADFLY_BASE_URL unset, resolution goes through majordomo's registry, so // With GADFLY_BASE_URL unset, resolution goes through majordomo's registry, so
// LLM_* env DSNs and registered aliases/tiers work too. // LLM_* env DSNs and registered aliases/tiers work too.
@@ -67,13 +99,21 @@ func resolveModel() (llm.Model, error) {
} }
// Endpoint override: construct the provider directly at the given URL. // Endpoint override: construct the provider directly at the given URL.
switch provider { // The openai-compat family is matched by the shared predicate, not a
case "openai", "openai-compatible": // repeated case list. The credential on THIS path is GADFLY_API_KEY; the
// built-ins' own KIMI_API_KEY / QWEN_API_KEY are read only on the registry
// path above, where GADFLY_BASE_URL is unset. The two paths never share a
// credential rule — assuming they do produces a config that passes every
// check and then 401s.
if isOpenAICompatProvider(provider) {
opts := []openai.Option{openai.WithBaseURL(baseURL)} opts := []openai.Option{openai.WithBaseURL(baseURL)}
if apiKey != "" { if apiKey != "" {
opts = append(opts, openai.WithAPIKey(apiKey)) opts = append(opts, openai.WithAPIKey(apiKey))
} }
return openai.New(opts...).Model(model) return openai.New(opts...).Model(model)
}
switch provider {
case "ollama", "ollama-cloud": case "ollama", "ollama-cloud":
opts := []ollama.Option{ollama.WithBaseURL(baseURL)} opts := []ollama.Option{ollama.WithBaseURL(baseURL)}
if apiKey != "" { if apiKey != "" {
@@ -108,7 +148,7 @@ func resolveModel() (llm.Model, error) {
} }
return google.New(opts...).Model(model) return google.New(opts...).Model(model)
default: default:
return nil, fmt.Errorf("GADFLY_BASE_URL is set but GADFLY_PROVIDER %q has no endpoint-override support (use openai/openai-compatible/ollama/llama-swap/foreman/anthropic/google, or unset GADFLY_BASE_URL to resolve via majordomo)", provider) return nil, fmt.Errorf("GADFLY_BASE_URL is set but GADFLY_PROVIDER %q has no endpoint-override support (use %s, or unset GADFLY_BASE_URL to resolve via majordomo)", provider, endpointProviderNames)
} }
} }
@@ -162,8 +202,8 @@ func buildSpec(provider, model string) string {
// entrypoint.sh's provider_of: the segment before the first "/" in GADFLY_MODEL, // entrypoint.sh's provider_of: the segment before the first "/" in GADFLY_MODEL,
// else GADFLY_PROVIDER, else the default (ollama-cloud). The binary reviews one // else GADFLY_PROVIDER, else the default (ollama-cloud). The binary reviews one
// model per invocation, so this is that model's provider — used to resolve // model per invocation, so this is that model's provider — used to resolve
// per-provider policy (e.g. lens concurrency) against the SAME provider keys // per-provider policy (e.g. the lens budget) against the SAME provider keys
// entrypoint uses for GADFLY_PROVIDER_CONCURRENCY. // entrypoint uses for GADFLY_PROVIDER_LENS_CONCURRENCY.
func modelProvider() string { func modelProvider() string {
model := strings.TrimSpace(os.Getenv("GADFLY_MODEL")) model := strings.TrimSpace(os.Getenv("GADFLY_MODEL"))
if pfx, _, ok := strings.Cut(model, "/"); ok { if pfx, _, ok := strings.Cut(model, "/"); ok {
@@ -188,8 +228,10 @@ func modelProvider() string {
// plaintext local Ollama (or foreman queue) works: // plaintext local Ollama (or foreman queue) works:
// GADFLY_ENDPOINT_BIGBOX="ollama|http://192.168.1.50:11434" // GADFLY_ENDPOINT_BIGBOX="ollama|http://192.168.1.50:11434"
// GADFLY_MODEL=bigbox/qwen2.5-coder:7b // GADFLY_MODEL=bigbox/qwen2.5-coder:7b
// provider is one of ollama/llama-swap(s)/foreman/openai/anthropic/google; "foreman" // provider is ollama/openai/anthropic/google/foreman/llama-swap(s) or an
// targets a foreman daemon (native Ollama on the wire): // openai-compat built-in (kimi, qwen) — endpointProviderNames is the
// authoritative list. "foreman" targets a foreman daemon (native Ollama
// on the wire):
// GADFLY_ENDPOINT_M1="foreman|http://foreman-m1:8080|tok" // GADFLY_ENDPOINT_M1="foreman|http://foreman-m1:8080|tok"
// //
// GADFLY_ALIAS_<NAME> = "<majordomo spec>" // GADFLY_ALIAS_<NAME> = "<majordomo spec>"
@@ -240,6 +282,16 @@ func endpointProvider(name, raw string) (llm.Provider, error) {
return nil, fmt.Errorf("missing base URL in %q", raw) return nil, fmt.Errorf("missing base URL in %q", raw)
} }
// Same shared predicate as resolveModel: the two must accept an identical
// set, and a hand-copied case list cannot guarantee that.
if isOpenAICompatProvider(provider) {
opts := []openai.Option{openai.WithName(name), openai.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, openai.WithAPIKey(key))
}
return openai.New(opts...), nil
}
switch provider { switch provider {
case "ollama", "ollama-cloud": case "ollama", "ollama-cloud":
opts := []ollama.Option{ollama.WithName(name), ollama.WithBaseURL(baseURL)} opts := []ollama.Option{ollama.WithName(name), ollama.WithBaseURL(baseURL)}
@@ -258,12 +310,6 @@ func endpointProvider(name, raw string) (llm.Provider, error) {
// its non-streaming degradation. Unlike the HTTPS-only LLM_* foreman:// // its non-streaming degradation. Unlike the HTTPS-only LLM_* foreman://
// DSN, the base URL here is verbatim, so a plaintext http:// foreman works. // DSN, the base URL here is verbatim, so a plaintext http:// foreman works.
return ollama.Foreman(baseURL, key, ollama.WithName(name)), nil return ollama.Foreman(baseURL, key, ollama.WithName(name)), nil
case "openai", "openai-compatible":
opts := []openai.Option{openai.WithName(name), openai.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, openai.WithAPIKey(key))
}
return openai.New(opts...), nil
case "anthropic": case "anthropic":
opts := []anthropic.Option{anthropic.WithName(name), anthropic.WithBaseURL(baseURL)} opts := []anthropic.Option{anthropic.WithName(name), anthropic.WithBaseURL(baseURL)}
if key != "" { if key != "" {
@@ -277,6 +323,6 @@ func endpointProvider(name, raw string) (llm.Provider, error) {
} }
return google.New(opts...), nil return google.New(opts...), nil
default: default:
return nil, fmt.Errorf("unknown provider %q (use ollama/llama-swap(s)/foreman/openai/openai-compatible/anthropic/google)", provider) return nil, fmt.Errorf("unknown provider %q (use %s)", provider, endpointProviderNames)
} }
} }
+68 -1
View File
@@ -1,6 +1,9 @@
package main package main
import "testing" import (
"strings"
"testing"
)
func TestEndpointProvider(t *testing.T) { func TestEndpointProvider(t *testing.T) {
t.Run("ollama http endpoint registers under its name", func(t *testing.T) { t.Run("ollama http endpoint registers under its name", func(t *testing.T) {
@@ -68,6 +71,70 @@ func TestEndpointProvider(t *testing.T) {
} }
} }
// TestOpenAICompatProvidersResolveOnBothPaths pins the two provider switches
// together. kimi and qwen are majordomo built-ins that ARE the openai client at
// a different base URL, and two independent places have to know it:
// resolveModel's GADFLY_BASE_URL override, and endpointProvider's
// GADFLY_ENDPOINT_* parser. A name accepted by one and rejected by the other is
// a provider that works when configured one way and errors the other, for no
// reason a user could guess. Asserting both from one table makes the pair fail
// together.
func TestOpenAICompatProvidersResolveOnBothPaths(t *testing.T) {
// Ranges the SHARED slice: a test that pins a list against drift must not
// be able to drift from it.
for _, provider := range openAICompatProviders {
t.Run(provider+" via GADFLY_ENDPOINT_*", func(t *testing.T) {
p, err := endpointProvider("ep", provider+"|https://host.example/v1|sk-x")
if err != nil {
t.Fatalf("endpointProvider(%q): %v", provider, err)
}
if p.Name() != "ep" {
t.Errorf("Name() = %q, want %q", p.Name(), "ep")
}
})
t.Run(provider+" via GADFLY_BASE_URL", func(t *testing.T) {
t.Setenv("GADFLY_PROVIDER", provider)
t.Setenv("GADFLY_BASE_URL", "https://host.example/v1")
t.Setenv("GADFLY_API_KEY", "sk-x")
t.Setenv("GADFLY_MODEL", "some-model")
if _, err := resolveModel(); err != nil {
t.Fatalf("resolveModel with GADFLY_PROVIDER=%q: %v", provider, err)
}
})
}
}
// TestEndpointProviderNamesAreAllAccepted keeps the operator-facing list
// honest: every name endpointProviderNames advertises must actually resolve.
// The constant is read by somebody whose config just failed, so a name listed
// there and rejected by the code sends them to debug a spelling that was never
// going to work.
func TestEndpointProviderNamesAreAllAccepted(t *testing.T) {
for _, name := range strings.Split(endpointProviderNames, "/") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
// Both switches, not one: this constant is the error text for BOTH
// GADFLY_ENDPOINT_* and GADFLY_BASE_URL, so a name accepted by only
// half of them still misleads whichever operator hits the other path.
t.Run(name+" via GADFLY_ENDPOINT_*", func(t *testing.T) {
if _, err := endpointProvider("ep", name+"|https://host.example/v1|sk-x"); err != nil {
t.Errorf("endpointProviderNames advertises %q but endpointProvider rejects it: %v", name, err)
}
})
t.Run(name+" via GADFLY_BASE_URL", func(t *testing.T) {
t.Setenv("GADFLY_PROVIDER", name)
t.Setenv("GADFLY_BASE_URL", "https://host.example/v1")
t.Setenv("GADFLY_API_KEY", "sk-x")
t.Setenv("GADFLY_MODEL", "some-model")
if _, err := resolveModel(); err != nil {
t.Errorf("endpointProviderNames advertises %q but resolveModel rejects it: %v", name, err)
}
})
}
}
func TestBuildSpec(t *testing.T) { func TestBuildSpec(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+53 -29
View File
@@ -33,9 +33,17 @@
# Optional config: # Optional config:
# GADFLY_MODELS comma-separated model ids/specs (alias: OLLAMA_REVIEW_MODELS) # GADFLY_MODELS comma-separated model ids/specs (alias: OLLAMA_REVIEW_MODELS)
# GADFLY_PROVIDER majordomo provider for bare model ids (default ollama-cloud; # GADFLY_PROVIDER majordomo provider for bare model ids (default ollama-cloud;
# e.g. "ollama" local, "openai", "anthropic", "google") # e.g. "ollama" local, "openai", "anthropic", "google",
# "qwen" Alibaba Model Studio, "kimi" Moonshot)
# GADFLY_BASE_URL override backend endpoint (OpenAI/Ollama-compatible servers) # GADFLY_BASE_URL override backend endpoint (OpenAI/Ollama-compatible servers)
# GADFLY_API_KEY provider key (else provider's standard env: OPENAI_API_KEY, …) # GADFLY_API_KEY provider key (else provider's standard env: OPENAI_API_KEY, …)
# QWEN_API_KEY Alibaba Model Studio key, for GADFLY_MODELS entries like
# "qwen/qwen3.8-max". Read ONLY by the qwen provider — it
# does not fall back to OPENAI_API_KEY, so a forgotten key
# is a clean skip notice naming this variable, not a 401.
# KIMI_API_KEY Moonshot key, same deal for "kimi/<model>". Distinct from
# the ollama-cloud "kimi-k2.6:cloud" entry, which is keyed
# by OLLAMA_CLOUD_API_KEY.
# CLAUDE_CODE_OAUTH_TOKEN auth for the claude-code engine (GADFLY_MODELS entry # CLAUDE_CODE_OAUTH_TOKEN auth for the claude-code engine (GADFLY_MODELS entry
# "claude-code"/"claude-code/<model>"); Pro/Max subscription # "claude-code"/"claude-code/<model>"); Pro/Max subscription
# token from `claude setup-token`. Else ANTHROPIC_API_KEY. # token from `claude setup-token`. Else ANTHROPIC_API_KEY.
@@ -198,14 +206,18 @@ export GADFLY_FINDINGS_TOKEN="${GADFLY_FINDINGS_TOKEN:-}"
# provider key envs (OPENAI_API_KEY, …) are inherited by run.sh and the binary. # provider key envs (OPENAI_API_KEY, …) are inherited by run.sh and the binary.
# #
# Concurrency: each PROVIDER is its own lane and lanes run in PARALLEL, so a fast # Concurrency: each PROVIDER is its own lane and lanes run in PARALLEL, so a fast
# cloud provider isn't stuck behind a slow local box. Within a lane, at most # cloud provider isn't stuck behind a slow local box. Within a lane ALL of the
# `cap` models run at once. cap = GADFLY_PROVIDER_CONCURRENCY's "provider=N" # provider's models run at once; the real throttle is a single PROVIDER-WIDE lens
# entry, else GADFLY_CONCURRENCY (default 1). A model's provider is the spec's # budget (a shared permit pool, seeded per lane) that every model's lenses draw
# first path segment ("m1pro/qwen3.6:35b-mlx" -> m1pro), or GADFLY_PROVIDER / # from — so total lens passes in flight per provider is bounded, but a model
# ollama-cloud for a bare id. Default (cap 1) keeps a single-provider pool fully # winding down to its last lens immediately yields its freed permits to another
# sequential, exactly as before. # model's queued lenses instead of holding a whole "model slot" (the old
# GADFLY_PROVIDER_CONCURRENCY model cap, now removed, caused that tail stall). The
# budget = GADFLY_PROVIDER_LENS_CONCURRENCY's "provider=N" entry, else
# GADFLY_LENS_CONCURRENCY (default 1). A model's provider is the spec's first path
# segment ("m1pro/qwen3.6:35b-mlx" -> m1pro), or GADFLY_PROVIDER / ollama-cloud
# for a bare id.
MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}" MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}"
DEFAULT_CONC="${GADFLY_CONCURRENCY:-1}"
# --- huge-PR downshift ------------------------------------------------------ # --- huge-PR downshift ------------------------------------------------------
# A very large diff is what burns the model budget: every review step re-sends # A very large diff is what burns the model budget: every review step re-sends
@@ -245,24 +257,33 @@ provider_of() { case "$1" in */*) echo "${1%%/*}";; *) echo "${GADFLY_PROVIDER:-
STATUS_DIR="${WORKDIR}/status" STATUS_DIR="${WORKDIR}/status"
status_file_for() { echo "${STATUS_DIR}/$(echo "$1" | tr -c '[:alnum:]._-' '_').json"; } status_file_for() { echo "${STATUS_DIR}/$(echo "$1" | tr -c '[:alnum:]._-' '_').json"; }
provider_cap() { # provider -> concurrency (override map "p=N,...", else default) # Root of the per-provider lens permit pools (one subdir per lane, seeded by
# run_lane). Cleared up front so a reused WORKDIR can't leak stale permit files.
LENS_SEM_ROOT="${WORKDIR}/lenssem"
rm -rf "$LENS_SEM_ROOT" 2>/dev/null || true
provider_lens_cap() { # provider -> provider-wide lens budget (permit-pool size)
local p="$1" item k v local p="$1" item k v
IFS=',' read -ra _caps <<< "${GADFLY_PROVIDER_CONCURRENCY:-}" IFS=',' read -ra _lcaps <<< "${GADFLY_PROVIDER_LENS_CONCURRENCY:-}"
for item in "${_caps[@]}"; do for item in "${_lcaps[@]}"; do
k="$(echo "${item%%=*}" | tr -d '[:space:]')" k="$(echo "${item%%=*}" | tr -d '[:space:]')"
v="$(echo "${item#*=}" | tr -d '[:space:]')" v="$(echo "${item#*=}" | tr -d '[:space:]')"
if [ "$k" = "$p" ] && [ -n "$v" ]; then echo "$v"; return; fi if [ "$k" = "$p" ] && [ -n "$v" ]; then echo "$v"; return; fi
done done
echo "$DEFAULT_CONC" echo "${GADFLY_LENS_CONCURRENCY:-1}"
} }
review_one() { review_one() {
local sf="" ff="" local m="$1" sem_dir="${2:-}" sem_size="${3:-}" sf="" ff=""
[ "${GADFLY_STATUS_BOARD:-1}" != "0" ] && sf="$(status_file_for "$1")" [ "${GADFLY_STATUS_BOARD:-1}" != "0" ] && sf="$(status_file_for "$m")"
[ "$CONSOLIDATE" = "1" ] && ff="$(findings_file_for "$1")" [ "$CONSOLIDATE" = "1" ] && ff="$(findings_file_for "$m")"
PROVIDER=ollama MODEL="$1" GADFLY_BIN="/usr/local/bin/gadfly" GADFLY_REPO_DIR="$REPO_DIR" \ # GADFLY_LENS_SEM_DIR/_SIZE point the binary at this provider's shared lens
# permit pool (empty => the binary just uses its in-process lens limit). These
# are inherited by the binary through run.sh's environment.
PROVIDER=ollama MODEL="$m" GADFLY_BIN="/usr/local/bin/gadfly" GADFLY_REPO_DIR="$REPO_DIR" \
GADFLY_STATUS_FILE="$sf" GADFLY_FINDINGS_OUT="$ff" GADFLY_CONSOLIDATE="$CONSOLIDATE" \ GADFLY_STATUS_FILE="$sf" GADFLY_FINDINGS_OUT="$ff" GADFLY_CONSOLIDATE="$CONSOLIDATE" \
bash "${SCRIPTS_DIR}/run.sh" || log "model $1 failed (continuing)" GADFLY_LENS_SEM_DIR="$sem_dir" GADFLY_LENS_SEM_SIZE="$sem_size" \
bash "${SCRIPTS_DIR}/run.sh" || log "model $m failed (continuing)"
# If the binary never wrote real status (run.sh skipped it: empty diff, no key, # If the binary never wrote real status (run.sh skipped it: empty diff, no key,
# binary missing), the pre-seed stays {started:0, done:false} and the board # binary missing), the pre-seed stays {started:0, done:false} and the board
# would show this model "waiting to start" forever and never reach N/N. Mark # would show this model "waiting to start" forever and never reach N/N. Mark
@@ -313,16 +334,19 @@ for m in "${MODEL_LIST[@]}"; do
case " $PROVIDERS " in *" $p "*) ;; *) PROVIDERS="${PROVIDERS}${PROVIDERS:+ }$p" ;; esac case " $PROVIDERS " in *" $p "*) ;; *) PROVIDERS="${PROVIDERS}${PROVIDERS:+ }$p" ;; esac
done done
run_lane() { # $1=provider: run its models, at most `cap` at a time run_lane() { # $1=provider: run ALL its models at once, throttled only by a shared
local p="$1" cap inflight=0 m # provider-wide lens permit pool (no per-model cap).
cap="$(provider_cap "$p")"; [ "$cap" -ge 1 ] 2>/dev/null || cap=1 local p="$1" budget sem_dir m
budget="$(provider_lens_cap "$p")"; [ "$budget" -ge 1 ] 2>/dev/null || budget=1
local mine=() local mine=()
for m in "${MODEL_LIST[@]}"; do [ "$(provider_of "$m")" = "$p" ] && mine+=("$m"); done for m in "${MODEL_LIST[@]}"; do [ "$(provider_of "$m")" = "$p" ] && mine+=("$m"); done
log "lane ${p}: cap ${cap}; models: ${mine[*]}" # Seed this provider's lens permit pool: a directory the binary flocks N permit
# files in (created lazily), one shared budget across every model in the lane.
sem_dir="${LENS_SEM_ROOT}/$(echo "$p" | tr -c '[:alnum:]._-' '_')"
mkdir -p "$sem_dir"
log "lane ${p}: lens budget ${budget} shared across ${#mine[@]} model(s): ${mine[*]}"
for m in "${mine[@]}"; do for m in "${mine[@]}"; do
review_one "$m" & review_one "$m" "$sem_dir" "$budget" &
inflight=$((inflight+1))
if [ "$inflight" -ge "$cap" ]; then wait -n 2>/dev/null || wait; inflight=$((inflight-1)); fi
done done
wait wait
} }
@@ -338,8 +362,8 @@ BOARD_PID=""
if [ "${GADFLY_STATUS_BOARD:-1}" != "0" ]; then if [ "${GADFLY_STATUS_BOARD:-1}" != "0" ]; then
rm -rf "$STATUS_DIR"; mkdir -p "$STATUS_DIR" rm -rf "$STATUS_DIR"; mkdir -p "$STATUS_DIR"
# Pre-seed every model as queued so the board shows the full swarm from t=0, # Pre-seed every model as queued so the board shows the full swarm from t=0,
# even models still waiting on their provider lane's concurrency cap. Each # even models whose lenses are still waiting on their provider's lens budget.
# binary overwrites its own file with real per-lens detail once it starts. # Each binary overwrites its own file with real per-lens detail once it starts.
for m in "${MODEL_LIST[@]}"; do for m in "${MODEL_LIST[@]}"; do
jq -n --arg model "$m" --arg provider "$(provider_of "$m")" \ jq -n --arg model "$m" --arg provider "$(provider_of "$m")" \
'{model:$model, provider:$provider, started:0, updated:0, done:false, lenses:[]}' \ '{model:$model, provider:$provider, started:0, updated:0, done:false, lenses:[]}' \
@@ -378,9 +402,9 @@ if [ "${GADFLY_PR_BUDGET_SECS:-0}" -gt 0 ] 2>/dev/null; then
fi fi
log "providers: ${PROVIDERS:-none}" log "providers: ${PROVIDERS:-none}"
# Each provider lane runs in parallel; cap is enforced within each lane. Track # Each provider lane runs in parallel; the shared lens budget throttles within
# the lane PIDs so we wait ONLY for the review work — not the status board, # each lane. Track the lane PIDs so we wait ONLY for the review work — not the
# which intentionally runs until we signal it below. # status board, which intentionally runs until we signal it below.
LANE_PIDS=() LANE_PIDS=()
for p in $PROVIDERS; do for p in $PROVIDERS; do
run_lane "$p" & run_lane "$p" &
+7 -7
View File
@@ -55,14 +55,14 @@ jobs:
# csv to choose; "all" for everything; or define custom ones via a repo # csv to choose; "all" for everything; or define custom ones via a repo
# .gadfly.yml / GADFLY_SPECIALIST_<NAME>. See README "Specialists". # .gadfly.yml / GADFLY_SPECIALIST_<NAME>. See README "Specialists".
GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS }} GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS }}
# Lens fan-out (optional; default 1 = lenses run sequentially within a # Concurrency (optional; default 1 = fully sequential per provider). The
# model). Raise it to run a model's lenses concurrently so each model # ONE throttle is a per-provider LENS BUDGET: the max lens passes (a lens =
# posts its comment sooner. Total in-flight requests = (models at once) # one specialist's review+recheck) in flight at once for a provider, shared
# × (lenses at once), so to fan out without oversubscribing a backend, # across ALL that provider's models — every model in a lane runs at once and
# keep its model cap low and raise its lens cap. Per-provider configurable # its lenses draw from the shared budget. Raise it to overlap lenses; set it
# via GADFLY_PROVIDER_LENS_CONCURRENCY (same lanes as the model map): # per provider with GADFLY_PROVIDER_LENS_CONCURRENCY:
# GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=1,m1=1"
# GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1=1" # GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1=1"
# (The old GADFLY_PROVIDER_CONCURRENCY model cap was removed and is ignored.)
# GADFLY_LENS_CONCURRENCY: ${{ vars.GADFLY_LENS_CONCURRENCY }} # GADFLY_LENS_CONCURRENCY: ${{ vars.GADFLY_LENS_CONCURRENCY }}
# GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ vars.GADFLY_PROVIDER_LENS_CONCURRENCY }} # GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ vars.GADFLY_PROVIDER_LENS_CONCURRENCY }}
# Live status board (optional; ON by default): one consolidated comment # Live status board (optional; ON by default): one consolidated comment
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Credential pre-flight for the agentic reviewer, in ONE definition.
#
# Sourced by run.sh (production) and by preflight_test.sh (the table test), so
# the tested bytes and the running bytes are the same. Keep it that way: a test
# that reimplements this logic can agree with a stale copy of it.
#
# Why pre-flight at all, when majordomo already fails closed with a 401:
# without it a missing key surfaces as five identical per-lens agent failures
# that name no variable, and the operator reads a stack trace to learn which
# secret they forgot to forward.
# gadfly_preflight_key <provider> -> echoes "" when the run may proceed, or the
# name of the environment variable the operator must set.
#
# Scope: the REGISTRY path only — GADFLY_BASE_URL unset — and deliberately so.
# The two resolution paths have DIFFERENT credential rules: with an explicit
# endpoint the credential is GADFLY_API_KEY (falling back to the client's own
# default, OPENAI_API_KEY for the openai family) and a built-in's own variable
# is never consulted; without one, the reverse. Applying either path's rule to
# the other yields a check that passes a run which then 401s — the precise
# failure this exists to prevent. So it covers the path whose rules it can state
# exactly and stays silent on the other. That is also the useful half: an
# override-path config is hand-written, while the registry path is what somebody
# hits by adding a model id to a var and forgetting the secret.
gadfly_preflight_key() {
local provider="$1" key_env="" key_hint=""
# Only the registry path has knowable credential rules — see above.
if [ -n "${GADFLY_BASE_URL:-}" ]; then
echo ""
return 0
fi
# A provider is absent from this table for one of TWO different reasons — do
# not assume the first one and add an arm:
# 1. It needs no key, or carries it in its endpoint/DSN: local ollama,
# llama-swap, foreman.
# 2. It needs a key but accepts more than one variable, so a single-name
# check would skip a correctly-configured run. **google** is this case:
# GOOGLE_API_KEY *or* GEMINI_API_KEY. Adding
# `google) key_env="GOOGLE_API_KEY"` would silently skip every reviewer
# configured with GEMINI_API_KEY. Pre-flighting google needs an
# either-variable check, not this table's one-name shape.
# ollama-cloud is checked on OLLAMA_API_KEY but hinted as OLLAMA_CLOUD_API_KEY:
# run.sh copies the consumer-facing OLLAMA_CLOUD_API_KEY secret into the
# OLLAMA_API_KEY the provider reads, BEFORE calling this. The hint names the
# variable the operator actually sets; the check reads the one the code uses.
# If that copy ever moves after this call, this arm reports a missing key for
# a configured run.
case "$provider" in
ollama-cloud) key_env="OLLAMA_API_KEY"; key_hint="OLLAMA_CLOUD_API_KEY" ;;
qwen) key_env="QWEN_API_KEY"; key_hint="QWEN_API_KEY" ;;
kimi) key_env="KIMI_API_KEY"; key_hint="KIMI_API_KEY" ;;
openai|openai-compatible) key_env="OPENAI_API_KEY"; key_hint="OPENAI_API_KEY" ;;
anthropic) key_env="ANTHROPIC_API_KEY"; key_hint="ANTHROPIC_API_KEY" ;;
esac
if [ -z "$key_env" ]; then
echo "" # provider needs no pre-flight
return 0
fi
# Indirect expansion (bash). Each majordomo built-in reads ONLY its own
# variable — cross-provider fallback is refused by design — so the named hint
# is always the actual fix.
if [ -n "${!key_env:-}" ]; then
echo ""
return 0
fi
echo "$key_hint"
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Table test for the credential pre-flight in preflight.sh.
#
# It SOURCES the real implementation rather than copying it, so there is no
# second definition that can pass while production fails.
#
# Run: scripts/preflight_test.sh (exit 0 = all cases pass)
set -u
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=preflight.sh
. "$SCRIPT_DIR/preflight.sh"
fail=0
check() { # description, want, got
if [ "$2" = "$3" ]; then
echo "ok $1"
else
echo "FAIL $1 — want '$2', got '$3'"
fail=1
fi
}
# probe <provider> [VAR=VAL ...] — run the real function in a clean environment
# under the same shell options production uses (set -u), so an unset-variable
# bug surfaces here instead of in a live review.
probe() {
local provider="$1"; shift
env -i PATH="$PATH" HOME="$HOME" "$@" bash -c "
set -u
. '$SCRIPT_DIR/preflight.sh'
gadfly_preflight_key '$provider'
"
}
echo "== registry path: keyed providers with no key must name their variable =="
check "qwen, no key" "QWEN_API_KEY" "$(probe qwen)"
check "kimi, no key" "KIMI_API_KEY" "$(probe kimi)"
check "ollama-cloud, no key" "OLLAMA_CLOUD_API_KEY" "$(probe ollama-cloud)"
check "openai, no key" "OPENAI_API_KEY" "$(probe openai)"
check "openai-compatible, none" "OPENAI_API_KEY" "$(probe openai-compatible)"
check "anthropic, no key" "ANTHROPIC_API_KEY" "$(probe anthropic)"
echo "== registry path: the provider's own key lets it run =="
check "qwen, keyed" "" "$(probe qwen QWEN_API_KEY=k)"
check "kimi, keyed" "" "$(probe kimi KIMI_API_KEY=k)"
check "ollama-cloud, keyed" "" "$(probe ollama-cloud OLLAMA_API_KEY=k)"
check "openai-compatible, keyed" "" "$(probe openai-compatible OPENAI_API_KEY=k)"
echo "== a wrong-provider key never satisfies a provider (no cross-fallback) =="
check "qwen w/ only OPENAI key" "QWEN_API_KEY" "$(probe qwen OPENAI_API_KEY=k)"
check "kimi w/ only QWEN key" "KIMI_API_KEY" "$(probe kimi QWEN_API_KEY=k)"
echo "== an empty-string key counts as missing, not present =="
check "qwen, empty key" "QWEN_API_KEY" "$(probe qwen QWEN_API_KEY=)"
echo "== GADFLY_API_KEY does NOT substitute on the registry path =="
# resolveModel reads GADFLY_API_KEY only after its `baseURL == ""` early
# return, so on this path the built-in reads its own variable and a set
# GADFLY_API_KEY changes nothing. Treating it as sufficient was a false pass.
check "qwen w/ GADFLY_API_KEY only" "QWEN_API_KEY" "$(probe qwen GADFLY_API_KEY=k)"
echo "== override path (GADFLY_BASE_URL set) is deliberately not pre-flighted =="
# The credential there is GADFLY_API_KEY with a client-specific fallback, and
# the built-ins' own variables are never read. Checking one path's rules
# against the other produced a false pass in BOTH directions, so this path is
# left alone rather than guessed at.
check "qwen + BASE_URL, no keys" "" "$(probe qwen GADFLY_BASE_URL=https://x)"
check "qwen + BASE_URL + own key" "" "$(probe qwen GADFLY_BASE_URL=https://x QWEN_API_KEY=k)"
check "qwen + BASE_URL + GADFLY key" "" "$(probe qwen GADFLY_BASE_URL=https://x GADFLY_API_KEY=k)"
check "openai + BASE_URL, no keys" "" "$(probe openai GADFLY_BASE_URL=https://x)"
echo "== providers needing no key are never blocked, with nothing set =="
for p in ollama llama-swap llama-swaps llamaswap llamaswaps foreman google gemini some-dsn-name; do
check "unkeyed $p" "" "$(probe "$p")"
done
# google is absent from the table on purpose: it accepts GOOGLE_API_KEY *or*
# GEMINI_API_KEY, so a one-name arm would skip a correctly-configured run.
check "google w/ only GEMINI_API_KEY" "" "$(probe google GEMINI_API_KEY=k)"
if [ "$fail" -ne 0 ]; then
echo "RESULT: preflight table FAILED"
exit 1
fi
echo "RESULT: all pre-flight cases pass"
+9 -4
View File
@@ -48,6 +48,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MAX_DIFF_CHARS="${MAX_DIFF_CHARS:-60000}" MAX_DIFF_CHARS="${MAX_DIFF_CHARS:-60000}"
# Credential pre-flight, shared verbatim with scripts/preflight_test.sh so the
# tested logic and the running logic are the same bytes.
# shellcheck source=preflight.sh
. "$SCRIPT_DIR/preflight.sh"
: "${GITEA_API:?GITEA_API required}" : "${GITEA_API:?GITEA_API required}"
: "${GITEA_TOKEN:?GITEA_TOKEN required}" : "${GITEA_TOKEN:?GITEA_TOKEN required}"
: "${PR:?PR required}" : "${PR:?PR required}"
@@ -162,10 +167,10 @@ case "$PROVIDER" in
fi fi
GADFLY_PROVIDER_EFF="$MODEL_PROVIDER" GADFLY_PROVIDER_EFF="$MODEL_PROVIDER"
# Only the default cloud provider strictly needs a key up front; local Ollama # Credential pre-flight — one definition, shared with preflight_test.sh.
# and other providers either need none or read their own standard env var. MISSING_KEY="$(gadfly_preflight_key "$GADFLY_PROVIDER_EFF")"
if [ "$GADFLY_PROVIDER_EFF" = "ollama-cloud" ] && [ -z "${OLLAMA_API_KEY:-}" ] && [ -z "${GADFLY_API_KEY:-}" ]; then if [ -n "$MISSING_KEY" ]; then
REVIEW="⚠️ No Ollama Cloud key configured (set \`OLLAMA_CLOUD_API_KEY\`) and \`GADFLY_PROVIDER\` is the default \`ollama-cloud\`; this reviewer was skipped." REVIEW="⚠️ No API key configured for provider \`${GADFLY_PROVIDER_EFF}\` (set \`${MISSING_KEY}\`); this reviewer was skipped."
else else
BIN="${GADFLY_BIN:-gadfly}" BIN="${GADFLY_BIN:-gadfly}"
if ! command -v "$BIN" >/dev/null 2>&1 && [ ! -x "$BIN" ]; then if ! command -v "$BIN" >/dev/null 2>&1 && [ ! -x "$BIN" ]; then