feat(concurrency): provider-wide lens budget, drop the model cap #27

Merged
steve merged 2 commits from feat/provider-wide-lens-budget into main 2026-07-18 16:48:26 +00:00
Owner

What & why

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 — so glm-5.2 winding down to one lens stalled kimi-k2.6 from starting even with 4 idle lens slots.

This collapses it to one throttle: a provider-wide lens budget shared across all of that provider's models.

How

  • entrypoint.sh now runs every model in a lane at once and seeds one cross-process permit pool per lane — a directory of N flock files, sized by GADFLY_PROVIDER_LENS_CONCURRENCYGADFLY_LENS_CONCURRENCY (default 1).
  • 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.
  • Falls back to the old in-process limit when no pool is set (local runs, tests).

Changes

  • cmd/gadfly/lenssem.go (new) — the flock permit pool (+ lenssem_test.go: exhaustion/release, blocking-then-cancel, never-exceeds-budget, unset-is-inert).
  • main.gorunSpecialists holds a shared permit per lens; fanout sized to the budget so a lone model can use all of it.
  • 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.

Invariants

  • Advisory-only preserved — no exit-code or blocking changes.
  • go build / go vet / go test ./... / gofmt -l all clean; bash -n entrypoint.sh clean.

🤖 Generated with Claude Code

## What & why 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 — so `glm-5.2` winding down to one lens stalled `kimi-k2.6` from starting even with 4 idle lens slots. This collapses it to **one throttle: a provider-wide lens budget shared across all of that provider's models.** ## How - `entrypoint.sh` now runs **every model in a lane at once** and seeds **one cross-process permit pool per lane** — a directory of N `flock` files, sized by `GADFLY_PROVIDER_LENS_CONCURRENCY` → `GADFLY_LENS_CONCURRENCY` (default 1). - 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. - Falls back to the old in-process limit when no pool is set (local runs, tests). ## Changes - **`cmd/gadfly/lenssem.go`** (new) — the flock permit pool (+ `lenssem_test.go`: exhaustion/release, blocking-then-cancel, never-exceeds-budget, unset-is-inert). - **`main.go`** — `runSpecialists` holds a shared permit per lens; fanout sized to the budget so a lone model can use all of it. - **`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. ## Invariants - Advisory-only preserved — no exit-code or blocking changes. - `go build` / `go vet` / `go test ./...` / `gofmt -l` all clean; `bash -n entrypoint.sh` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
steve added 1 commit 2026-07-18 16:22:44 +00:00
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
74831368ab
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]>

🪰 Gadfly — live review status

5/5 reviewers finished · updated 2026-07-18 16:37:32Z

claude-code/sonnet · claude-code — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

glm-5.2:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Reviewed
  • performance — Minor issues
  • error-handling — Minor issues

kimi-k2.6:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — Minor issues
  • error-handling — Blocking issues found

opencode/glm-5.2:cloud · opencode — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

opencode/kimi-k2.6:cloud · opencode — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Blocking issues found

Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.

<!-- gadfly-status-board --> ## 🪰 Gadfly — live review status 5/5 reviewers finished · updated 2026-07-18 16:37:32Z #### `claude-code/sonnet` · claude-code — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `glm-5.2:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Reviewed - ✅ **performance** — Minor issues - ✅ **error-handling** — Minor issues #### `kimi-k2.6:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — Minor issues - ✅ **error-handling** — Blocking issues found #### `opencode/glm-5.2:cloud` · opencode — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `opencode/kimi-k2.6:cloud` · opencode — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Blocking issues found <sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>
gitea-actions bot reviewed 2026-07-18 16:37:32 +00:00
gitea-actions bot left a comment

🪰 Gadfly consensus review — 5 inline findings on changed lines. See the consensus comment for the full ranked summary.

Advisory only — does not block merge.

<!-- gadfly-inline-review --> 🪰 **Gadfly consensus review** — 5 inline findings on changed lines. See the consensus comment for the full ranked summary. <sub>Advisory only — does not block merge.</sub>
@@ -0,0 +36,4 @@
// 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 {

🟡 GADFLY_LENS_SEM_DIR set but invalid/blank GADFLY_LENS_SEM_SIZE silently degrades to unthrottled (no budget)

error-handling · flagged by 1 model

  • cmd/gadfly/lenssem.go:39-49 + entrypoint.sh:332,338,277 — size/budget mismatch between shell and binary is not reconciled. run_lane clamps budget to ≥1 (entrypoint.sh:332) and passes it as GADFLY_LENS_SEM_SIZE (entrypoint.sh:277), but activeLensSem independently re-reads GADFLY_LENS_SEM_SIZE via envInt("GADFLY_LENS_SEM_SIZE", 0) (which returns the default 0 for blank/garbage/≤0, main.go:411-421), then returns nil when n < 1 (lenssem.go:45-47). When `sem == nil…

🪰 Gadfly · advisory

🟡 **GADFLY_LENS_SEM_DIR set but invalid/blank GADFLY_LENS_SEM_SIZE silently degrades to unthrottled (no budget)** _error-handling · flagged by 1 model_ - **`cmd/gadfly/lenssem.go:39-49` + `entrypoint.sh:332,338,277` — size/budget mismatch between shell and binary is not reconciled.** `run_lane` clamps `budget` to ≥1 (`entrypoint.sh:332`) and passes it as `GADFLY_LENS_SEM_SIZE` (`entrypoint.sh:277`), but `activeLensSem` independently re-reads `GADFLY_LENS_SEM_SIZE` via `envInt("GADFLY_LENS_SEM_SIZE", 0)` (which returns the default `0` for blank/garbage/≤0, `main.go:411-421`), then returns `nil` when `n < 1` (`lenssem.go:45-47`). When `sem == nil… <sub>🪰 Gadfly · advisory</sub>
@@ -0,0 +51,4 @@
// acquire blocks until a permit is free (or ctx is done) and returns a release
// func. On cancellation it returns a no-op release plus ctx.Err(), so callers can
// surface the lens as "did not run" rather than leaking a permit.
func (s *lensSem) acquire(ctx context.Context) (func(), error) {

🟠 Provider-wide lens permit acquire has no timeout; a broken/unusable permit directory (entrypoint.sh's unchecked mkdir -p) hangs every lens in the lane forever with no backstop unless GADFLY_PR_BUDGET_SECS is explicitly set

error-handling, performance · flagged by 3 models

  • cmd/gadfly/lenssem.go:54-65 (lensSem.acquire) + cmd/gadfly/main.go:274,291 + entrypoint.sh:337-338 — the provider-wide permit acquire has no bound independent of the permit directory being usable, and the directory's creation error is swallowed. - runSpecialists calls fanout.Run(context.Background(), ...) (main.go:274), and the closure calls sem.acquire(ctx) with that same undeadlined context (main.go:291). acquire only returns early on ctx.Done() (lenssem.go:59-63)…

🪰 Gadfly · advisory

🟠 **Provider-wide lens permit acquire has no timeout; a broken/unusable permit directory (entrypoint.sh's unchecked `mkdir -p`) hangs every lens in the lane forever with no backstop unless GADFLY_PR_BUDGET_SECS is explicitly set** _error-handling, performance · flagged by 3 models_ - **`cmd/gadfly/lenssem.go:54-65` (`lensSem.acquire`) + `cmd/gadfly/main.go:274,291` + `entrypoint.sh:337-338`** — the provider-wide permit acquire has no bound independent of the permit directory being usable, and the directory's creation error is swallowed. - `runSpecialists` calls `fanout.Run(context.Background(), ...)` (`main.go:274`), and the closure calls `sem.acquire(ctx)` with that same undeadlined context (`main.go:291`). `acquire` only returns early on `ctx.Done()` (`lenssem.go:59-63`)… <sub>🪰 Gadfly · advisory</sub>
@@ -0,0 +59,4 @@
select {
case <-ctx.Done():
return func() {}, ctx.Err()
case <-time.After(lensSemPollInterval):

🟡 time.After leaks timer on context cancellation in acquire loop

performance · flagged by 1 model

  • cmd/gadfly/lenssem.go:62time.After timer leak on context cancellation. acquire uses time.After(lensSemPollInterval) inside a select. If the caller’s context is cancelled before the timer fires, the timer is not stopped and lives until it fires, creating garbage on every cancelled wait. With many blocked models this is minor but measurable. Suggested fix: use time.NewTimer + timer.Stop() in the ctx.Done() branch.

🪰 Gadfly · advisory

🟡 **time.After leaks timer on context cancellation in acquire loop** _performance · flagged by 1 model_ * **`cmd/gadfly/lenssem.go:62` — `time.After` timer leak on context cancellation.** `acquire` uses `time.After(lensSemPollInterval)` inside a `select`. If the caller’s context is cancelled before the timer fires, the timer is not stopped and lives until it fires, creating garbage on every cancelled wait. With many blocked models this is minor but measurable. **Suggested fix:** use `time.NewTimer` + `timer.Stop()` in the `ctx.Done()` branch. <sub>🪰 Gadfly · advisory</sub>
@@ -0,0 +69,4 @@
// (entrypoint.sh only guarantees the directory exists), so a fresh pool needs no
// seeding step beyond mkdir. 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) {

🔴 Silent OpenFile errors cause infinite spin-poll in acquire, stalling review if permit directory is missing or unwritable

error-handling, maintainability · flagged by 4 models

  • cmd/gadfly/lenssem.go:76tryAcquire silently swallows os.OpenFile errors and acquire will spin-poll forever when the permit directory is missing or unwritable. If GADFLY_LENS_SEM_DIR points to a non-existent path (or permissions change after entrypoint.sh creates it), every call to tryAcquire fails on all permit files, returns (nil, false), and acquire loops every 150 ms doing useless syscalls. Because fanout.Run is driven by context.Background(), that context never…

🪰 Gadfly · advisory

🔴 **Silent OpenFile errors cause infinite spin-poll in acquire, stalling review if permit directory is missing or unwritable** _error-handling, maintainability · flagged by 4 models_ - **`cmd/gadfly/lenssem.go:76`** – `tryAcquire` silently swallows `os.OpenFile` errors and `acquire` will spin-poll forever when the permit directory is missing or unwritable. If `GADFLY_LENS_SEM_DIR` points to a non-existent path (or permissions change after `entrypoint.sh` creates it), every call to `tryAcquire` fails on all permit files, returns `(nil, false)`, and `acquire` loops every 150 ms doing useless syscalls. Because `fanout.Run` is driven by `context.Background()`, that context never… <sub>🪰 Gadfly · advisory</sub>
@@ -261,0 +265,4 @@
// 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.
sem := activeLensSem()

🟠 maxConcurrent equals total budget allows one model to starve others in the same lane

maintainability, performance · flagged by 2 models

  • cmd/gadfly/main.go:268-272 — Model starvation within a shared lane. When sem != nil, maxConcurrent is set to sem.size (the total cross-process lens budget). fanout.Run eagerly refills a finished slot with the next queued specialist, so a model with more lenses than the budget tends to immediately re-acquire the freed permit. Other models in the same lane are blocked in sem.acquire polling every 150 ms and usually lose the race. The result: lenses across models do not interleave…

🪰 Gadfly · advisory

🟠 **maxConcurrent equals total budget allows one model to starve others in the same lane** _maintainability, performance · flagged by 2 models_ * **`cmd/gadfly/main.go:268-272` — Model starvation within a shared lane.** When `sem != nil`, `maxConcurrent` is set to `sem.size` (the total cross-process lens budget). `fanout.Run` eagerly refills a finished slot with the next queued specialist, so a model with more lenses than the budget tends to immediately re-acquire the freed permit. Other models in the same lane are blocked in `sem.acquire` polling every 150 ms and usually lose the race. The result: lenses across models do not interleave… <sub>🪰 Gadfly · advisory</sub>

🪰 Gadfly review — consensus across 5 models

Verdict: Blocking issues found · 11 findings (6 with multi-model agreement)

Finding Where Models Lens
🔴 Silent OpenFile errors cause infinite spin-poll in acquire, stalling review if permit directory is missing or unwritable cmd/gadfly/lenssem.go:72 4/5 error-handling, maintainability
🟠 Provider-wide lens permit acquire has no timeout; a broken/unusable permit directory (entrypoint.sh's unchecked 'mkdir -p') hangs every lens in the lane forever with no backstop unless GADFLY_PR_BUDGET_SECS is explicitly set cmd/gadfly/lenssem.go:54 3/5 error-handling, performance
🟡 Stale comment still references removed GADFLY_PROVIDER_CONCURRENCY instead of GADFLY_PROVIDER_LENS_CONCURRENCY cmd/gadfly/lens_concurrency_test.go:159 3/5 maintainability
🟠 maxConcurrent equals total budget allows one model to starve others in the same lane cmd/gadfly/main.go:268 2/5 maintainability, performance
🟡 Stale comment describes defaultLensConcurrency as per-model instead of provider-wide cmd/gadfly/main.go:100 2/5 maintainability
🟡 Stale comment references removed provider lane model cap entrypoint.sh:357 2/5 maintainability
5 single-model findings (lower confidence)
Finding Where Model Lens
🟠 run_lane launches all models concurrently with no process/memory bound; only lens passes are throttled, causing N concurrent executor builds + /api/show HTTP lookups + resident processes per lane (regression vs old wait -n cap), worst at default budget=1 entrypoint.sh:340 glm-5.2:cloud performance
🟡 GADFLY_LENS_SEM_DIR set but invalid/blank GADFLY_LENS_SEM_SIZE silently degrades to unthrottled (no budget) cmd/gadfly/lenssem.go:39 glm-5.2:cloud error-handling
🟡 time.After leaks timer on context cancellation in acquire loop cmd/gadfly/lenssem.go:62 kimi-k2.6:cloud performance
🟡 runSpecialists doc comment opening contradicts the new provider-wide-pool behavior added below it cmd/gadfly/main.go:248 glm-5.2:cloud maintainability
🟡 Misleading 'cap is enforced within each lane' comment after model cap removal entrypoint.sh:397 kimi-k2.6:cloud maintainability
Per-model detail
claude-code/sonnet (claude-code) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

Security Review

VERDICT: No material issues found

This PR is an internal concurrency refactor (flock-based permit pool replacing the two-level model/lens cap). I read the full new lenssem.go, its test file, and traced the permit-pool wiring through entrypoint.shrun.sh → the binary to check for injection, unsafe file handling, and secret leakage.

Things I specifically checked and found sound:

  • Permit file paths aren't attacker-influenced. sem_dir is built from the provider name (entrypoint.sh:319), which comes from GADFLY_MODELS/GADFLY_PROVIDER — trusted consumer/workflow config, not PR body/title content. It's sanitized with the same tr -c '[:alnum:]._-' '_' pattern already used for status_file_for/findings_file_for elsewhere in this file, so no path traversal via provider name.
  • Permit files carry no data (lenssem.go:75, opened O_RDWR|O_CREATE, mode 0o600) — just used for flock, so there's no secret-leakage or content-injection surface even if another local process could read them.
  • Env plumbing is a straight pass-through, not shell-evaluated: GADFLY_LENS_SEM_DIR/GADFLY_LENS_SEM_SIZE flow entrypoint.sh → run.sh's inherited environment → the binary's os.Getenv (verified run.sh:180-191 and lenssem.go:40-48); envInt (main.go:411) safely rejects non-numeric/non-positive input instead of doing anything unsafe with it.
  • No new command construction from untrusted input — all shell interpolations of $p/$m in the new/changed entrypoint.sh code are either sanitized filenames or values drawn from trusted config, consistent with pre-existing patterns in the same file (status_file_for, findings_file_for).
  • The error surfaced when a permit acquire is canceled (main.go: "⚠️ This reviewer did not run: %v") only ever carries ctx.Err() (context.Canceled/DeadlineExceeded), not any internal state or secrets.

One thing I considered but didn't report as a finding: tryAcquire opens permit.N without O_EXCL, which in a shared, world-writable /tmp could theoretically be a symlink-following TOCTOU if a malicious co-tenant could pre-plant a symlink at the predictable WORKDIR-derived path. I checked this isn't new exposure — STATUS_DIR/FINDINGS_DIR under the same WORKDIR already followed this exact predictable-path pattern before this PR, and the runtime is an isolated per-job CI container (not a shared multi-tenant host), so this doesn't rise to a reportable finding for this diff.

🎯 Correctness — No material issues found

All confined to lenssem.go and main.go, consistent with the diff. I've verified the core concurrency mechanism (Go flock permit pool, bash lane orchestration, and their interaction) end-to-end and found the logic sound: permit acquisition happens before the per-lens timeout starts (so queue wait isn't counted against GADFLY_TIMEOUT_SECS), sem.size correctly bounds the in-process fanout so a lone model can claim the whole provider budget while still yielding to siblings, provider_of/modelProvider() use identical segment-splitting so bash and Go agree on lane keys, and no stale references to the removed GADFLY_PROVIDER_CONCURRENCY/provider_cap remain outside comments.

VERDICT: No material issues found

The concurrency redesign (flock-based cross-process permit pool in lenssem.go, its wiring into runSpecialists in main.go, and the bash-side run_lane/provider_lens_cap changes in entrypoint.sh) is logically consistent. I traced the full flow: entrypoint.sh computes budget once per lane and passes sem_dir/sem_size to every model's review_onerun.sh inherits GADFLY_LENS_SEM_DIR/GADFLY_LENS_SEM_SIZE as ambient env (the explicit-var-prefix form doesn't strip pre-existing exported vars, so they correctly reach the gadfly binary even though run.sh doesn't re-list them) → activeLensSem()/lensConcurrency() resolve them the same way provider_of/modelProvider() already agreed on before this PR. Permit acquisition happens before reviewWithSpecialist's own timeout context starts, so queueing time isn't charged against GADFLY_TIMEOUT_SECS, and defer release() is correctly skipped on the error path (function already returned) and correctly reached on success. The new lenssem_test.go exercises exhaustion, blocking+cancel, and the peak-never-exceeds-budget invariant in a way that matches the production semantics (flock is per open-file-description, so same-process double-opens validly model cross-process contention). No stale references to the removed GADFLY_PROVIDER_CONCURRENCY/provider_cap remain in code paths (only in comments/deprecation notes, as intended).

🧹 Code cleanliness & maintainability — Minor issues

All other repo mentions of GADFLY_PROVIDER_CONCURRENCY explicitly frame it as removed/historical (CLAUDE.md, README, entrypoint.sh, examples, review-reusable.yml). The one at lens_concurrency_test.go:159 is indeed the sole remaining spot that presents it as a still-live rule ("same lane rule entrypoint.sh uses for"), confirming the draft's claim exactly, including that it's the only outlier.

VERDICT: Minor issues

  • cmd/gadfly/lens_concurrency_test.go:159 — The comment on TestLensConcurrency says the per-provider lane matching exercised by the test is "the same lane rule entrypoint.sh uses for GADFLY_PROVIDER_CONCURRENCY". That variable's per-provider model-cap concept is exactly what this PR removes: entrypoint.sh:207 now says the GADFLY_PROVIDER_CONCURRENCY model cap was "now removed", and the lane resolution the test actually exercises (lensConcurrency()/providerOverride() in main.go:321-328) is keyed on GADFLY_PROVIDER_LENS_CONCURRENCY only. Every other file touched by this PR (main.go, entrypoint.sh, README, CLAUDE.md, examples, review-reusable.yml) consistently frames GADFLY_PROVIDER_CONCURRENCY as removed/historical, but this pre-existing test comment still reads as if it's the live rule — it's the only remaining place in the repo doing so. Test correctness is unaffected (the test itself only sets/reads GADFLY_PROVIDER_LENS_CONCURRENCY); this is a stale-comment/doc-drift issue of the kind CLAUDE.md's own maintenance rule calls out. Fix: reword to reference GADFLY_PROVIDER_LENS_CONCURRENCY.
Performance — No material issues found

VERDICT: No material issues found

I reviewed cmd/gadfly/lenssem.go, the runSpecialists/lensConcurrency changes in cmd/gadfly/main.go, and the entrypoint.sh run_lane/review_one rework, since this PR is entirely about concurrency/throttling.

  • The flock permit pool (lenssem.go:72-86) uses a 150 ms poll loop (lenssem.go:33,62) rather than a blocking primitive. Each blocked goroutine re-sweeps up to size permit files per poll (open+flock+maybe-close). With the documented default budget of 1 this is a single syscall attempt per 150 ms per waiter; even at larger configured budgets with many models in a lane, the sweep is O(budget) and the poll interval is coarse relative to lens durations ("many seconds to minutes," per the file's own comment at lenssem.go:31-32). I verified tryAcquire/acquire do exactly this and confirmed runSpecialists (main.go:268-297) only calls sem.acquire once per lens (not in a hot loop), so this doesn't compound — a documented, bounded tradeoff, not a hot-path regression.
  • entrypoint.sh (entrypoint.sh:329-339) removed the wait -n-based launch throttle, so run_lane now spawns every model in a provider lane as a full OS process concurrently, instead of the old default-1 sequential launch. This does raise peak concurrent process/memory usage during model-process startup (repo read, diff embedding, executus setup) for lanes with many models — but it's the explicit, documented purpose of this PR ("every model in the lane runs concurrently… the only throttle is a lens budget," per the README/CLAUDE.md updates), not an accidental side effect, and the actual expensive work (LLM calls) stays gated by the shared permit pool. Not flagging as a regression since it's the intended design, but noting it since it's the one place total resource concurrency is now uncapped where it previously wasn't.
  • runSpecialists (main.go:268-275) sizes MaxConcurrent to sem.size when the pool is active, so a lone model's fanout can use the whole provider budget as intended; confirmed fanout.Run still returns in input order and per-lens timeouts (reviewTimeout() in reviewWithSpecialist, main.go:355-357) are unaffected by permit queueing time — no busy-work or leaked goroutines on cancellation (main.go:290-297 returns a no-op release and marks the lens finished).

Nothing here amounts to an N+1, unbounded growth, or hot-loop regression within the performance lens — the design is a deliberate trade of cross-process simplicity (flock polling) for a small, bounded, well-documented overhead.

🧯 Error handling & edge cases — Minor issues

All corroborated — no test covers a permanently-unusable permit directory. The draft's central finding holds up completely against the code.

VERDICT: Minor issues

  • cmd/gadfly/lenssem.go:54-65 (lensSem.acquire) + cmd/gadfly/main.go:274,291 + entrypoint.sh:337-338 — the provider-wide permit acquire has no bound independent of the permit directory being usable, and the directory's creation error is swallowed.
    • runSpecialists calls fanout.Run(context.Background(), ...) (main.go:274), and the closure calls sem.acquire(ctx) with that same undeadlined context (main.go:291). acquire only returns early on ctx.Done() (lenssem.go:59-63); with an undeadlined background context that never fires, so acquire polls tryAcquire every 150ms forever if it can never lock a permit.
    • tryAcquire (lenssem.go:72-86) tries os.OpenFile(path, O_RDWR|O_CREATE, 0o600) on each permit.<i> file inside s.dir; on any error it just continues to the next index, with no error surfaced.
    • entrypoint.sh:338 does mkdir -p "$sem_dir" with no error check, then unconditionally passes that (possibly nonexistent/unusable) dir to every model via GADFLY_LENS_SEM_DIR (entrypoint.sh:277).
    • Net effect: if the permit directory can't be created or written (disk full, permission issue, weird WORKDIR), every lens for every model in that provider's lane calls activeLensSem() → non-nil (GADFLY_LENS_SEM_DIR/_SIZE both set) → tryAcquire fails every sweep → acquire spins forever with zero timeout, since the per-lens deadline (GADFLY_TIMEOUT_SECS, via reviewTimeout() inside reviewWithSpecialist, main.go:356) is created only after a permit is acquired (main.go:290-299) — confirmed reviewWithSpecialist builds its own independent context.WithTimeout(context.Background(), ...), unrelated to the fanout context acquire is blocked on. The only remaining backstop is GADFLY_PR_BUDGET_SECS, which defaults to 0 (disabled, entrypoint.sh:379). This directly undermines the documented invariant "a slow lens can't starve the others" — a broken permit pool now hangs the whole lane with no built-in protection.
    • This failure mode also isn't covered by lenssem_test.go: all tests use a real t.TempDir() that always succeeds, so the "acquire on a permanently-unusable pool" case is untested.
    • Suggested fix: give sem.acquire its own bounded fallback (e.g., treat repeated tryAcquire failures for reasons other than "locked" — i.e., open errors — as fatal after a short grace period, or have entrypoint.sh verify mkdir -p "$sem_dir" succeeded and fall back to unsetting GADFLY_LENS_SEM_DIR/leaving the model on in-process concurrency instead of a broken shared pool).

Everything else checked out: activeLensSem's n < 1 guard correctly makes a zero/invalid GADFLY_LENS_SEM_SIZE inert (lenssem.go:44-47), envInt rejects non-positive values (main.go:411-421) and entrypoint.sh:332 guards a non-numeric/sub-1 budget back to 1, and statusWriter.set (status.go:75-...) does a simple by-name lens update with no index/nil assumptions.

glm-5.2:cloud (ollama-cloud) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

Verdict: No material issues found

I reviewed the change through the security lens (authn/authz, injection, path traversal, secret leakage, unsafe handling of untrusted data, insecure defaults) and verified the tainted-input paths against the checked-out code.

What I checked and why it's clean:

  • Permit-pool path construction (entrypoint.sh:337, cmd/gadfly/lenssem.go:74). The sem dir is ${LENS_SEM_ROOT}/<sanitized provider>, where the provider key is run through tr -c '[:alnum:]._-' '_' — so a model spec/provider string can't inject ../ or shell metacharacters into the directory path. LENS_SEM_ROOT is ${WORKDIR}/lenssem with WORKDIR defaulting to /tmp/gadfly (entrypoint.sh:63), set by the container, not by PR-controlled input. Permit filenames are fmt.Sprintf("permit.%d", i) with i < size — no traversal possible.

  • File creation mode (cmd/gadfly/lenssem.go:75). Permit files are opened O_RDWR|O_CREATE with mode 0o600 (owner-only read/write) — no world-readable/writable default, and the dir is per-run under a private WORKDIR, so no cross-tenant leak/collision surface.

  • Env-var sourcing (cmd/gadfly/lenssem.go:39-48). GADFLY_LENS_SEM_DIR/GADFLY_LENS_SEM_SIZE are set by entrypoint.sh/run.sh from container-derived values, not from PR title/body/comment text (those flow into the review prompt, not these vars). envInt (main.go:411) rejects non-positive/unparseable sizes, so a malformed size degrades safely to "no pool" rather than a zero-size or huge allocation. No injection via these inputs.

  • flock descriptor handling. syscall.Flock(int(f.Fd()), ...) operates on the already-opened fd; the release path closes the *os.File, dropping the lock. No fd leak / privilege surface introduced. The lock is advisory (LOCK_EX|LOCK_NB), consistent with advisory-only review behavior — no new blocking/exit-code enforcement (a security-relevant invariant for a review bot that must not block merges).

  • providerOverride (main.go:337-348). Parses the provider=N,... map with strings.Cut/strconv.Atoi and validates n > 0; untrusted map entries can't cause a negative/zero fanout or panic. No command/SQL/template injection — pure in-process parsing.

No secret/credential values pass through any new code path, and no new untrusted-data sink was introduced. I could not identify any material security issue within this lens.

🎯 Correctness — No material issues found

Confirmed. The design is correct.

VERDICT: No material issues found

  • I verified the central correctness claim — that the new design bounds total in-flight lens passes per provider to the budget N — by reading cmd/gadfly/main.go:259-318 and cmd/gadfly/lenssem.go. entrypoint.sh seeds one shared sem_dir per lane with budget=N for every model in the lane (entrypoint.sh run_lane / review_one), each model process sets maxConcurrent = sem.size (main.go:268-272), and every lens goroutine blocks in sem.acquire before running (main.go:290-297). tryAcquire (lenssem.go) sweeps permit.0..size-1 with O_RDWR|O_CREATE + LOCK_EX|LOCK_NB; flock exclusion is per open-file-description, so cross-process mutual exclusion on the shared N files genuinely caps concurrent holders at N. Correct.
  • Default-budget boundary re-derived: defaultLensConcurrency = 1 (main.go:103) and entrypoint falls back to GADFLY_LENS_CONCURRENCY:-1. With budget 1, at most one lens pass is in flight per provider across all its models — consistent with the documented "default keeps a provider fully sequential." Equivalent to the prior default behavior (one model at a time × one lens at a time = 1 in flight). Correct.
  • Context cancellation path: runSpecialists passes context.Background() into fanout.Run (main.go:274), so the ctx a lens sees is effectively un-cancellable; sem.acquire will poll until a permit frees rather than surfacing ctx.Err(). That is acceptable for the design (lenses have their own per-lens timeout inside reviewWithSpecialist at main.go:356) and the cancellation branch is still exercised by tests via a context with deadline. No correctness defect.
  • The acquire-on-cancellation returns a no-op release (lenssem.go acquire), so no permit leak on ctx-done. Verified.
  • Stale-pool cleanup: entrypoint.sh rm -rf "$LENS_SEM_ROOT" before seeding prevents a reused WORKDIR from carrying over old permit files. Verified in the diff.

Nothing in the correctness lane is materially wrong.

🧹 Code cleanliness & maintainability — Reviewed

I have enough to verify all three findings.

Finding 1 (main.go:248): Confirmed. Lines 248-249 still read "up to GADFLY_LENS_CONCURRENCY lenses run concurrently (the default of 1 keeps the suite sequential, exactly as before)", while the appended paragraph (254-258) and the code (270-272, maxConcurrent = sem.size) make the active ceiling the provider-wide pool size. Minor drift. Keep as small.

Finding 2 (lens_concurrency_test.go:159): Confirmed. Line 159 references "the same lane rule entrypoint.sh uses for GADFLY_PROVIDER_CONCURRENCY", a var now removed/ignored. providerOverride/lensConcurrency now key on GADFLY_PROVIDER_LENS_CONCURRENCY (main.go:320-336). Stale reference. Keep as trivial.

Finding 3 (lenssem.go:67): Not confirmed. tryAcquire iterates i = 0..size-1 and opens each permit.{i} with O_CREAT, so the full permit.0..size-1 set is created — not "files named by first successful index." The "no seeding step beyond mkdir" comment is accurate: entrypoint guarantees only the dir; files appear lazily on first open. Cross-process recreation of the same-named files is exactly the lazy-creation behavior the comment describes, not an oversell. Drop.

Performance — Minor issues

Both findings are verified against the actual code. Let me confirm the key claims:

Finding 1run_lane (entrypoint.sh:329-344) loops over all models in mine and launches review_one … & for each with no inflight cap, then wait. Only the lens permit pool (sem_dir/budget) throttles lens passes. newReviewExecutor (main.go:187) calls resolveContextTokens (executus.go:239, 351-370) at executor-build time — a synchronous /api/show HTTP call with a 5s timeout — before runSpecialists (where sem.acquire happens at main.go:291). So N models in a lane spawn N concurrent processes, each building an executor + doing an HTTP lookup before any lens throttle. Confirmed.

Finding 2acquire (lenssem.go:54-65) polls every 150ms via tryAcquire (72-85), which opens + flocks + closes each of size permit files. Each blocked goroutine does this independently → O(blocked × budget) syscalls per tick. Confirmed (the draft's exact (M·L−B) formula slightly overstates it since fanout.MaxConcurrent caps concurrent in-process goroutines at the budget, but the core scaling claim and "negligible at small budgets" conclusion hold — if anything the actual cost is tighter than stated).

Verdict: Minor issues

  • entrypoint.sh:340-343 — unbounded model-process fanout per lane (resource regression). run_lane now launches every model in the lane at once (review_one … & with no wait -n cap) and only throttles lens passes via the flock pool. The lens budget bounds concurrent LLM calls but places no bound on resident model processes. Each model is a full Go binary that, at startup, builds the run executor and does a synchronous context-token HTTP lookup (resolveContextTokens/api/show, up to contextTokenLookupTimeout = 5s, executus.go:351-370) before any lens permit is acquired (main.go:291). So with the default budget of 1 and, say, 8 models in one provider lane, you get 8 concurrent full processes — 8 concurrent /api/show HTTP calls, 8 resident executors/tool-registries/repoFS trees, 8 agent-loop stacks — even though only one lens pass runs at a time. The old cap/wait -n path bounded exactly this. The PR's intent is to run all models concurrently for LLM-load sharing, but the loss of any process/memory bound per lane is a real oversubscription regression for lanes with many models, worst at the default budget. Suggestion: document that model count per lane should stay modest, or keep a soft model-process cap independent of the lens budget so a 20-model lane with budget 1 doesn't spawn 20 resident binaries.

  • cmd/gadfly/lenssem.go:54-65,72-85 — polling sweep cost scales with blocked_goroutines × size. Every lens goroutine blocked in acquire re-runs tryAcquire every 150ms, and each sweep does os.OpenFile(O_CREATE) + syscall.Flock(LOCK_NB) + Close() on all size permit files. For blocked goroutines each touching B files per tick → blocked × B open/flock/close syscalls per 150ms. For typical small budgets (1-3) and handfuls of lenses this is negligible (~10² syscalls/sec), so flagging only as a minor note, not a blocker: it stays well-behaved at documented scales but is O(blocked × budget) and would get loud if someone set a large budget with many queued lenses. No change required; worth a comment noting the scaling if large budgets are ever supported.

🧯 Error handling & edge cases — Minor issues

Verdict: Minor issues

Through the error-handling / edge-case lens, the new lensSem has one material gap plus a smaller robustness nit. The happy paths and the panic/cancel handling are sound.

Findings

  • cmd/gadfly/lenssem.go:72-85 — broken/nonexistent pool dir → silent infinite poll (no fallback, no timeout).
    tryAcquire does os.OpenFile(...) per permit and on any error just continues; if every open fails (the GADFLY_LENS_SEM_DIR doesn't exist, is unreadable, was removed mid-run, or the mkdir -p in run_lane failed), tryAcquire always returns false. acquire then loops tryAcquire + time.After(150ms) until ctx.Done().
    The ctx handed to the fn is the one fanout.Run derives from context.Background() at main.go:274 — i.e. never cancelled (the per-lens timeout only starts inside reviewWithSpecialist at main.go:356, after the permit is acquired via sem.acquire at main.go:291). Verified: the WithTimeout is created at main.go:356, reached only after sem.acquire returns and defer release() is registered (main.go:296), so the queue wait has no deadline. So a misconfigured/stale sem dir makes every lens in that process spin forever at ~7 Hz, never running, never erroring — the lens hangs at "queued" on the board and the process never completes. (In normal operation entrypoint.sh:338 does mkdir -p "$sem_dir", so this requires the dir to be absent/broken/unreadable at runtime; it's a robustness gap, not a happy-path bug.) Suggested fix: if OpenFile fails for all size files in one sweep, either fail fast (return an error so the lens surfaces as "did not run") or treat a permanently-unopenable dir as "pool inert" and fall back to running unthrottled; at minimum, distinguish "all files errored" from "all files locked" so a broken dir isn't indistinguishable from a busy pool.

  • cmd/gadfly/lenssem.go:39-49 + entrypoint.sh:332,338,277 — size/budget mismatch between shell and binary is not reconciled.
    run_lane clamps budget to ≥1 (entrypoint.sh:332) and passes it as GADFLY_LENS_SEM_SIZE (entrypoint.sh:277), but activeLensSem independently re-reads GADFLY_LENS_SEM_SIZE via envInt("GADFLY_LENS_SEM_SIZE", 0) (which returns the default 0 for blank/garbage/≤0, main.go:411-421), then returns nil when n < 1 (lenssem.go:45-47). When sem == nil, main.go:270-272 skips sizing the fanout to the pool and no permit is acquired, so the binary silently runs unthrottled by the cross-process budget. The two paths agree today (entrypoint always sets a valid size), but the binary's pool size is sourced from an env var that any wrapper could rewrite; a stale/blank/invalid GADFLY_LENS_SEM_SIZE with a non-empty GADFLY_LENS_SEM_DIR degrades to "no throttle" rather than failing. Low likelihood, but it's an unguarded edge. Suggested fix: when GADFLY_LENS_SEM_DIR is set but GADFLY_LENS_SEM_SIZE is invalid, log a warning or fail the lens rather than silently dropping the budget.

Verified safe (not reported)

  • Panic-while-holding-permit is handled: defer release() is registered after defer recover() (main.go:281,296), so on panic release() runs first (closes the fd, dropping the flock) and then recover() catches the value — no permit leak on panic.
  • acquire cancellation path returns a no-op release + ctx.Err(), and the caller marks the lens finished + errored (main.go:292-294) — no leaked permit on cancel.
  • envInt/providerOverride both reject ≤0 and non-numeric, so a bad GADFLY_LENS_SEM_SIZE/provider map can't produce a negative or zero size that would make tryAcquire's loop a no-op or divide-by-zero.
kimi-k2.6:cloud (ollama-cloud) — Blocking issues found

Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

cmd/gadfly/lenssem.go:40,74-75 — Path traversal via unsanitized GADFLY_LENS_SEM_DIR
activeLensSem reads GADFLY_LENS_SEM_DIR from the environment and tryAcquire feeds it directly into filepath.Join without validation. An attacker who can set this variable (e.g. in a standalone/local run or a compromised environment) can inject ../ sequences, causing the binary to create and acquire exclusive flock locks on files outside the intended semaphore directory. While entrypoint.sh sets the value safely, the binary itself should not trust the path. Fix: validate that the path is absolute and optionally enforce it sits under an expected base directory (e.g. /tmp/gadfly/lenssem); reject or sanitize any .. segments.

🎯 Correctness — No material issues found

No material issues found

I investigated the core correctness of the new flock-based permit pool (cmd/gadfly/lenssem.go), its integration into runSpecialists (cmd/gadfly/main.go), and the entrypoint.sh orchestration. After thorough verification:

  • tryAcquire / acquire logic: The non-blocking sweep over permit files with flock(LOCK_EX|LOCK_NB) is correct. On success, the returned release func closes the fd, dropping the kernel-held flock. On cancellation, a no-op release is returned — no permit is leaked.
  • Process-death safety: flock is tied to the open file description; the kernel automatically releases it on process exit. This matches the PR's stated invariant and is semantically correct.
  • Cross-process coordination: Entrypoint.sh seeds the per-lane directory and passes GADFLY_LENS_SEM_DIR + GADFLY_LENS_SEM_SIZE. The binary's activeLensSem() reads these and falls back to in-process throttling when unset. Verified that envInt correctly handles zero/negative/missing values by returning nil (safe fallback).
  • Fanout sizing: Setting maxConcurrent = sem.size when the pool is active ensures a lone model can consume the full provider budget, while extra goroutines simply block in sem.acquire() until a permit frees. The interaction with fanout.Run is sound — permits are acquired inside the worker, so MaxConcurrent can safely exceed the actual pool size.
  • Panic safety: The defer release() is set after successful acquisition, and reviewWithSpecialist has its own internal timeout. Even if a panic occurs after permit acquisition, stack unwinding will call the deferred release. Verified the ordering in main.go:290-297.

No logic bugs, incorrect behavior, or semantic/domain errors were identified.

🧹 Code cleanliness & maintainability — Minor issues

Minor issues

  • entrypoint.sh:357 — Stale comment referencing removed model cap: "even models still waiting on their provider lane's concurrency cap" but the PR removed the per-provider model cap (GADFLY_PROVIDER_CONCURRENCY). Under the new design every model in a lane starts immediately; the only thing that could delay a model is the lens permit pool, not a model slot. This will mislead future maintainers trying to understand lane behavior.
    Fix: Update to something like "even models whose lenses are waiting on the shared provider-wide lens budget."

  • entrypoint.sh:397 — Ambiguous/misleading comment after cap removal: "cap is enforced within each lane" is no longer true — there is no model cap inside a lane. The lens budget is enforced by the binary's flock pool, not by run_lane.
    Fix: Rephrase to "lens budget is shared across all models in each lane" or similar.

  • cmd/gadfly/main.go:100-103 — Stale doc comment on defaultLensConcurrency: it still says "how many specialist lenses run at once within a single model." Under the new design this value seeds the provider-wide budget when entrypoint.sh sets the shared pool, and a lone model can use the whole budget.
    Fix: Update the comment to reflect provider-wide semantics, e.g. "defaultLensConcurrency is the provider-wide lens budget when running under entrypoint.sh (shared across all models in the lane); standalone it caps in-process fanout."

  • cmd/gadfly/lenssem.go:76 — Silent swallowing of os.OpenFile errors in tryAcquire(): if the permit directory is missing, unwritable, or the process hits EMFILE, every sweep fails but the code simply continues with no diagnostic. A misconfigured pool will cause acquire() to spin forever (retrying every 150 ms) and the lens will appear permanently queued on the status board, with no clue in logs about why.
    Fix: At minimum log the first error encountered per sweep, or surface a distinct error when no permit files can be opened so operators can see the pool is broken.

Performance — Minor issues

Verdict: Minor issues

  • cmd/gadfly/main.go:268-272 — Model starvation within a shared lane.
    When sem != nil, maxConcurrent is set to sem.size (the total cross-process lens budget). fanout.Run eagerly refills a finished slot with the next queued specialist, so a model with more lenses than the budget tends to immediately re-acquire the freed permit. Other models in the same lane are blocked in sem.acquire polling every 150 ms and usually lose the race. The result: lenses across models do not interleave well when one model has significantly more specialists than the budget, effectively serialising the lane again.
    Suggested fix: either size the in-process fanout smaller than the total budget (e.g. max(1, sem.size/2) or simply lensConcurrency() without the override) so that no single model can greedily refill all permits, or document that the shared budget works best when models have comparable lens counts.

  • cmd/gadfly/lenssem.go:62time.After timer leak on context cancellation.
    acquire uses time.After(lensSemPollInterval) inside a select. If the caller’s context is cancelled before the timer fires, the timer is not stopped and lives until it fires, creating garbage on every cancelled wait. With many blocked models this is minor but measurable.
    Suggested fix: use time.NewTimer + timer.Stop() in the ctx.Done() branch.

🧯 Error handling & edge cases — Blocking issues found

Blocking issues found

  • cmd/gadfly/lenssem.go:76tryAcquire silently swallows os.OpenFile errors and acquire will spin-poll forever when the permit directory is missing or unwritable. If GADFLY_LENS_SEM_DIR points to a non-existent path (or permissions change after entrypoint.sh creates it), every call to tryAcquire fails on all permit files, returns (nil, false), and acquire loops every 150 ms doing useless syscalls. Because fanout.Run is driven by context.Background(), that context never cancels on its own, so the goroutine hangs until the outer job timeout (or indefinitely in local/test misconfigurations). The binary should distinguish transient lock contention (EWOULDBLOCK) from permanent file-system errors and bail out fast on the latter. Suggested fix: in tryAcquire, propagate non-EWOULDBLOCK errors up to acquire, which should return the error immediately rather than retrying.
    (Verified by reading lenssem.go and tracing the call path from main.go:290 through acquire / tryAcquire.)
opencode/glm-5.2:cloud (opencode) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

I'll examine the new lenssem.go and related code for security issues.
I've reviewed the change through the security lens. The new concurrency mechanism touches file I/O (os.OpenFile with O_CREATE, flock), env-var-driven paths, and bash path construction — all worth scrutinizing for injection/path-traversal/symlink/permission issues.

Verification performed:

  • Read cmd/gadfly/lenssem.go fully and envInt (main.go:411): GADFLY_LENS_SEM_SIZE is parsed to int and rejected if <1, and the permit filename is fmt.Sprintf("permit.%d", i) with i bounded by size — no path component from env reaches the filename; only the directory does.
  • Read entrypoint.sh:252-339: LENS_SEM_ROOT is a fixed path ${WORKDIR}/lenssem (operator-controlled, not PR-tainted), cleared once and recreated per-lane. sem_dir derives from provider p which comes from provider_of of GADFLY_MODELS — operator config, not PR content — and is sanitized via tr -c '[:alnum:]._-' '_'. provider_lens_cap compares and echoes values without eval.
  • Permit files created with 0o600 and O_RDWR|O_CREATE; flock is non-blocking (LOCK_NB), no blocking syscall that could hang on a hostile file.
  • All inputs feeding GADFLY_LENS_SEM_DIR/GADFLY_LENS_SEM_SIZE/GADFLY_PROVIDER_LENS_CONCURRENCY flow from workflow inputs/owner vars (operator-configured), not from the PR diff or PR metadata. No tainted PR input reaches the permit-pool paths.

No symlink-race concern either: the directory is under the runner's private WORKDIR, the rm -rf + mkdir -p happens at setup before any model process starts, and a PR author has no write access to that filesystem.

VERDICT: No material issues found

  • No injection/path-traversal: the only env-controlled path segment is the directory; the permit filename is a bounded integer format string. The directory is operator-configured (WORKDIR/lenssem), not PR-tainted, and the provider subdirectory is sanitized.
  • No unsafe deserialization or credential leakage: the change only adds flock-based file locking and env plumbing; no secrets are read, logged, or forwarded (the GADFLY_LENS_SEM_* envs carry only a directory path and an integer).
  • Permissions are appropriately restrictive (0o600 on permit files).
  • No PR-controlled input reaches the semaphore paths or the bash provider_lens_cap/run_lane logic.
🎯 Correctness — No material issues found

Now let me think about correctness. Key observations:

  1. Lens budget vs. model fanout sizing: runSpecialists sets MaxConcurrent = sem.size when sem is active. With multiple models in a lane, each model gets sem.size goroutines all competing for the same sem.size permits across processes. That's the intent.

  2. The pool semantics: tryAcquire opens permit.<i> files and flocks them. There's a subtle concern — file creation is shared across processes. Two processes both call OpenFile(O_RDWR|O_CREATE) on permit.0; one locks it, the other gets EWOULDBLOCK. The second then tries permit.1. That's correct.

  3. Concern: file descriptor leak / partial acquire in tryAcquire: if OpenFile succeeds but Flock fails, f.Close() is called. Good. No leak.

  4. The release funcfunc() { f.Close() }. Closing the fd releases the flock. Correct.

  5. Potential correctness issue: In acquire, when ctx is cancelled, returns func(){}. Good — no permit held.

  6. Status board race: if sem.acquire fails (ctx done), the code sets sw.set(sp.Name, lensFinished, "", true) and returns an errored result. But note: this is errored: true and out: "⚠️ This reviewer did not run: ...". Verdict is verdictUnknown. That's fine.

  7. Concern about budget exceeding: With size N pool, multiple models each spawn N goroutines (MaxConcurrent = sem.size). All goroutines across all models compete for N permits. Max in-flight = N. ✓

Let me check fanout.Run semantics to confirm MaxConcurrent controls goroutine count, not blocking:
Now let me think through correctness carefully:

  1. Key concern: MaxConcurrent = sem.size. With sem.size permits across multiple model processes, each process sets its in-process fanout to sem.size. So if 3 models each spawn 3 goroutines (size=3), you have 9 goroutines all trying to acquire from a pool of 3 permits. Max in-flight = 3. ✓ Correct.

  2. But — within a single model process (no entrypoint pool, local runs): sem == nil, maxConcurrent = lensConcurrency(). That's the original behavior. ✓

  3. Subtle concern: When sem is set, lensConcurrency() (the provider override / scalar) is no longer applied to the in-process fanout — only sem.size is. Both come from the same source (GADFLY_PROVIDER_LENS_CONCURRENCY → entrypoint provider_lens_capsem_size; and lensConcurrency()GADFLY_PROVIDER_LENS_CONCURRENCY/GADFLY_LENS_CONCURRENCY). So they should agree. But there's a subtle bug: envInt (used by the Go side reading GADFLY_LENS_SEM_SIZE) and bash's provider_lens_cap parse the same value, so they agree.

  4. Important issue: When sem != nil, maxConcurrent = sem.size. But the lensConcurrency() per-provider override is NOT consulted by the binary when sem is set. That's fine because entrypoint already consulted it. BUT — what if the user sets GADFLY_PROVIDER_LENS_CONCURRENCY directly in the binary's env without entrypoint? Then lensConcurrency() would return the right value but sem.size (from GADFLY_LENS_SEM_SIZE) might differ. That's a niche case not applicable here (entrypoint always sets both consistently). Not a real bug.

  5. More important: the acquire poll loop creates time.After channels that leak until fired — minor, but with long waits it accumulates timers. Actually each iteration creates a new timer; the previous one is GC'd. Not a real bug for this scale.

  6. Real concern: tryAcquire opens files with O_CREAT — but it never cleans up the permit files. On a reused WORKDIR, stale permit files accumulate but they're just empty files; flock state is per-open-FD, so stale files don't hold locks. Plus entrypoint does rm -rf "$LENS_SEM_ROOT" up front. ✓

  7. Concern about flock behavior on close vs. fd reuse: f.Close() releases the flock. Standard. ✓

  8. Potential correctness issue I want to verify: in runSpecialists, when sem.acquire returns an error (ctx cancelled), the code sets sw.set(sp.Name, lensFinished, "", true) and returns errored: true. But the panic recovery defer runs AFTER — wait, the defer is registered before the sem.acquire call. If sem.acquire errors, we return; the defer runs recover() which is nil, so no-op. ✓

  9. **Subtle: the defer release() is registered before sw.set(sp.Name, lensRunning, ...). If reviewWithSpecialist panics, the defer recovers, sets res, sets sw finished. Then the defer release() runs (LIFO), releasing the permit. ✓ Correct order.

Let me double check the fanout.Run context — it's passed context.Background(). So the per-lens ctx used in sem.acquire(ctx) is context.Background()never cancelled! That means if a lens is queued waiting for a permit and the overall run is killed, the goroutine stays blocked. But since fanout.Run is given context.Background() too, the lenses aren't cancelled by fanout anyway. The process being killed drops the flocks. So no leak in practice.

But wait — there's a real concern: the acquire uses the ctx passed by fanout, which is context.Background(). So acquire will block forever if no permit frees. If all permits are held by deadlocked goroutines... but goroutines release on completion. Fine.

Let me check one more thing — the MaxConcurrent semantics in fanout. If MaxConcurrent controls how many goroutines run the fn concurrently, then with sem active and size N, the fanout spawns up to N concurrent fn calls. Each fn tries sem.acquire(ctx). Since fanout already limits to N concurrent, and the pool is N, the in-process fanout never blocks on the sem — the sem only throttles ACROSS processes. That's actually the design: "extra goroutines simply block in sem.acquire until a permit frees" — but with MaxConcurrent=N=sem.size, there are no extra goroutines within one process. The blocking happens ACROSS processes. ✓ Correct.

Now a subtle bug: with 2 models in a lane, each spawns N goroutines (MaxConcurrent=N), but the pool is N total. So model A's N goroutines grab all N permits; model B's N goroutines block. As A's lenses finish, permits free, B's goroutines acquire. ✓ This is exactly the intended design.

Let me verify the provider_lens_cap bash function parsing matches the Go providerOverride:

Bash: splits on ,, for each item, k="${item%%=*}", v="${item#*=}", trimmed. If k == p and v non-empty, echo v, return.

Go: strings.Cut(item, "="), strings.TrimSpace(k) != provider skip, strconv.Atoi(TrimSpace(v)), n > 0.

Edge case: GADFLY_PROVIDER_LENS_CONCURRENCY="ollama-cloud=3,m1pro=1". Both parse the same. ✓

Edge case: an item without = like "foo": bash k="foo", v="foo" (since ${item#*=} on no-= returns the whole). k != p for any real provider, skipped. Go: Cut returns ok=false, skip. ✓

Edge case: trailing comma or empty item. Bash: k="", v="", v empty → skip. Go: Cut("", "=") returns ("", "", false), skip. ✓

Now let me check: is there a scenario where sem.size from GADFLY_LENS_SEM_SIZE disagrees with lensConcurrency()? In the binary, sem.size is read via envInt("GADFLY_LENS_SEM_SIZE", 0). lensConcurrency() reads GADFLY_PROVIDER_LENS_CONCURRENCY/GADFLY_LENS_CONCURRENCY. Entrypoint sets GADFLY_LENS_SEM_SIZE=$budget where budget=provider_lens_cap($p), which reads the same env vars. So they agree for the model's provider. ✓

I think the change is correct. Let me just double-check one potential issue: when sem != nil and MaxConcurrent = sem.size, but the number of specialists is LESS than sem.size. Then fanout spawns len(specialists) goroutines (assuming fanout caps at min(MaxConcurrent, len)). Each acquires a permit. Fine. ✓

Let me verify there's no issue with acquire returning a no-op release on ctx-done but the caller already having... no, it returns immediately.

One last thing — the lensSemPollInterval = 150ms. With 12 goroutines polling a 3-permit pool, each polling every 150ms. Negligible. ✓

I'm confident this is correct. Let me check if there's any issue with the O_CREAT race: two processes call OpenFile("permit.0", O_RDWR|O_CREATE). Both succeed (one creates, one opens existing). Both try Flock(LOCK_EX|LOCK_NB). One wins, one gets EWOULDBLOCK and closes. ✓ Standard flock semantics.

Verdict: No material correctness issues found.

Let me do one final sanity check on the status board state when sem.acquire fails — the lens is marked lensFinished with errored=true. But it was never marked lensRunning. So the board goes queued → finished (errored). That's fine — the board logic checks State != lensFinished for "still running" (status.go:91). ✓

I'm done. No material correctness issues.## Verdict: No material issues found

I reviewed the change through the correctness lens — the flock permit-pool semantics, the in-process fanout sizing, the bash/Go env-map parsing parity, and the cancellation/panic-release orderings. Everything checks out.

Verified by reading the actual code (not just the diff):

  • cmd/gadfly/lenssem.go:72 (tryAcquire)O_RDWR|O_CREATE + FLOCK_EX|LOCK_NB is the correct non-blocking exclusive-lock pattern. Failure path closes f (no fd leak); success returns func(){ f.Close() }, and closing the fd drops the flock (lock lives on the open file description). Cross-process exclusion works because flock is per-open-file-description, so two OpenFile calls on the same permit.N conflict. Confirmed at lenssem.go:75-83.
  • cmd/gadfly/main.go:268-272 (fanout sizing) — when sem != nil, MaxConcurrent = sem.size. With N permits shared across M model processes, each process spawns ≤N goroutines; total in-flight is bounded by the pool size N, not N×M. A lone model can use the whole budget; extra goroutines block in sem.acquire. Correct.
  • cmd/gadfly/main.go:287-297 (acquire/release ordering)defer release() is registered after a successful acquire and before reviewWithSpecialist. The panic-recovery defer (registered earlier, so LIFO-runs later) sets res/sw.set(lensFinished); then defer release() runs and frees the permit. On ctx-done from acquire, no permit was held, so the early return is leak-free (acquire returns a no-op release, lenssem.go:61). Verified against lenssem.go:54-65.
  • cmd/gadfly/main.go:327-348 vs entrypoint.sh:257-266providerOverride (Go) and provider_lens_cap (bash) parse the identical provider=N,... syntax. I walked edge cases: missing = (bash v becomes the whole item but k≠p skips; Go Cut returns ok=false and skips), empty/trailing items (both skip), whitespace (both TrimSpace/tr -d). They agree, so GADFLY_LENS_SEM_SIZE (entrypoint) and lensConcurrency() (binary) resolve to the same value for the model's provider — the in-process cap and the cross-process pool are consistent.
  • entrypoint.sh:254-255 + 337-338LENS_SEM_ROOT is rm -rf'd up front and each lane mkdir -ps its subdir; stale permit files from a reused WORKDIR can't leak lock state (flock is per-open-FD, and the dir is wiped anyway). Lazy O_CREAT in tryAcquire means no separate seeding step is needed. Correct.
  • entrypoint.sh:329-344 (run_lane) — every model in the lane is backgrounded with no per-model cap; wait joins them. The old inflight/wait -n model-cap gate is fully removed. Matches the documented invariant.
  • acquire poll loop (lenssem.go:54-64) — uses ctx.Done() + time.After(lensSemPollInterval) in a select, so cancellation beats the timer; returns ctx.Err() with a no-op release. No permit leak on cancellation. The 150 ms poll is negligible against lens passes that run for seconds-to-minutes.

No correctness defects found in my lane.

🧹 Code cleanliness & maintainability — Minor issues

Let me check whether the comment at line 159 is the only stale reference, and look at lens_concurrency_test more broadly.
Verdict: Minor issues

  • cmd/gadfly/lens_concurrency_test.go:159 — Stale comment after this PR: // lane rule entrypoint.sh uses for GADFLY_PROVIDER_CONCURRENCY). The diff updated the analogous references in main.go:248-258, model.go:165, and providerOverride's doc (main.go:336) to point at GADFLY_PROVIDER_LENS_CONCURRENCY, but missed this one in the test file. Verified by grep — entrypoint.sh no longer references GADFLY_PROVIDER_CONCURRENCY as a lane-key source; provider_lens_cap keys off GADFLY_PROVIDER_LENS_CONCURRENCY. Low-impact doc rot that future readers will trip on. Suggested fix: rewrite to …same lane rule entrypoint.sh uses for GADFLY_PROVIDER_LENS_CONCURRENCY).

  • cmd/gadfly/main.go:269-272maxConcurrent := lensConcurrency() is computed and then unconditionally overwritten when sem != nil. Trivial wasted work, but more importantly slightly misleading: the comment block above explains the intent, yet the discarded value reads as if it were a fallback floor rather than a pure no-op when the pool is active. Suggested fix: maxConcurrent := lensConcurrency(); if sem != nil { maxConcurrent = sem.size } is fine as-is, but consider gating the call: maxConcurrent := sem.size; if sem == nil { maxConcurrent = lensConcurrency() } so the discard is not visually suggestive of a merge.

  • cmd/gadfly/lenssem.go:72-86 tryAcquire always sweeps 0..size-1, always preferring permit.0. Not a correctness bug (verified — flock contention is symmetric and the test TestLensSemNeverExceedsSize confirms the budget holds), but it concentrates contention on the low-index files under heavy load. A rand.Perm-style or start = (start+1) % size rotating index would spread load. Minor; acceptable for the few-lines-of-stdlib goal the file states.

No material issues found in the lenssem implementation, tests, entrypoint wiring, or docs otherwise. The gadfly-findings block below reflects only the genuine findings.

Performance — No material issues found

I'll review this through the performance lens. Let me examine the key files.
I have enough context. Let me check the in-process fanout sizing once more for the "extra goroutines block" claim — whether fanout.Run with MaxConcurrent larger than needed actually blocks harmlessly.

The design: maxConcurrent = sem.size (e.g., 3). fanout runs up to 3 lenses concurrently in-process; each one calls sem.acquire() which flocks a permit. With 2 models in lane each running 3 lenses, total 6 in-process goroutines contend for 3 flock permits across 2 processes. The in-process MaxConcurrent=3 is just the per-model goroutine ceiling; cross-process flock does the real throttling. That works.

Let me verify the polling concern: with a 150ms poll, when a permit frees, up to 150ms latency before another lens starts. For lens passes that run seconds-to-minutes, negligible. Fine.

One concern: tryAcquire does O(size) syscalls every poll. With budget=3, that's 3 OpenFile+Flock attempts every 150ms per blocked lens — trivial. With a larger budget (say 16) and many blocked lenses, still trivial. Not a hot path.

Let me check the entrypoint.shrun_lane backgrounds ALL models at once (review_one "$m" "$sem_dir" "$budget" &), no per-model cap. With budget=1, all M models start, each lens flocks the single permit — serial across all of them. That matches the design. Good.

Performance lens assessment: no N+1, no unbounded growth, no quadratic behavior, no missing pagination. The pool is bounded by size. Permit files created lazily (bounded to size). Polling is bounded and coarse. No regressions found.

Verdict: No material issues found

  • The lensSem poll interval (150ms) is coarse but appropriate — lens passes run for seconds-to-minutes, so worst-case permit-acquisition latency is negligible relative to lens duration, and it avoids busy-waiting (verified lenssem.go:33,54-65).
  • tryAcquire does O(size) open+flock syscalls per poll, but size is the small per-provider lens budget and polling only happens while blocked (not on the hot path of an actively-running lens); for the budgets in play this is trivial (lenssem.go:72-86).
  • runSpecialists sizes in-process MaxConcurrent to sem.size, so a lone model in a lane can consume the whole provider budget; extra goroutines block in sem.acquire rather than spinning — no busy-wait, no allocation churn (main.go:268-272, 290-297).
  • entrypoint.sh run_lane backgrounds all of a lane's models at once with no per-model cap, but the shared flock pool of size permits is the real bound, so model count does not cause unbounded in-flight lens passes (entrypoint.sh:329-344).
  • Permit files are created lazily but bounded to size per provider; rm -rf "$LENS_SEM_ROOT" at startup prevents unbounded stale-file growth across reused WORKDIRs (entrypoint.sh:255-256).
  • No new allocations or copies introduced on the per-lens hot path beyond a single os.OpenFile per permit acquire and a deferred close.
🧯 Error handling & edge cases — Minor issues

I have verified all the key claims in the draft against the actual code:

  • fanout.Run(context.Background(), ...) at main.go:274 — confirmed; the worker ctx derives from an uncancellable background context.
  • sem.acquire(ctx) is called at main.go:291 with that ctx, and reviewWithSpecialist (which holds the per-lens timeout at main.go:356) is called after acquire returns at main.go:299 — so the timeout cannot rescue a stuck acquire.
  • tryAcquire silently continues on os.OpenFile error (lenssem.go:76-77) with no logging, conflating "all permits held" with "open failed."
  • entrypoint mkdir -p "$sem_dir" at entrypoint.sh:338 and rm -rf "$LENS_SEM_ROOT" once at startup (entrypoint.sh:255) — so in the supported path the dir exists, but a misconfigured GADFLY_LENS_SEM_DIR in a local run would hang silently.

The single finding survives verification. The draft's other observations (panic defer ordering, garbage-budget fallback, SIGKILL flock release, empty-concurrency fallback) were all confirmed correct but were explicitly noted as not findings, so they don't appear below.

VERDICT: Minor issues

  • cmd/gadfly/lenssem.go:54-65 / cmd/gadfly/lenssem.go:75-78acquire can hang forever on a broken pool dir, silently. runSpecialists calls fanout.Run(context.Background(), …) (main.go:274), so the worker ctx passed to sem.acquire(ctx) (main.go:291) is never cancelled by fanout — the ctx.Done() branch in acquire is unreachable in production. If tryAcquire's os.OpenFile fails for every permit file (dir deleted mid-run, permission revoked, ENOSPC), acquire polls every 150ms indefinitely with no error escalation and no log. The per-lens timeout in reviewWithSpecialist (main.go:356) is applied after acquire returns (main.go:299), so it cannot rescue a stuck acquire — the lens (and the model process) hangs without producing the "did not run" advisory result the cancellation comment at main.go:289 promises. tryAcquire also conflates "all permits held" with "open failed" — both return (nil, false) and the continue at lenssem.go:76-77 swallows the os.OpenFile error, so a sibling model process can't distinguish "wait" from "give up." In the supported entrypoint path this is unlikely (the lane's sem_dir is mkdir -p'd at entrypoint.sh:338 and nothing deletes it during the run), but a misconfigured GADFLY_LENS_SEM_DIR in a local/manual run degrades to a silent hang rather than the in-process fallback. Suggest: log on the first OpenFile failure and, if every file fails on a sweep, return a sentinel error (so the lens surfaces as "did not run" and the binary can fall back to in-process throttling) or bound the retries. (Verified against lenssem.go, main.go:274,291,299,356, entrypoint.sh:255,338.)
opencode/kimi-k2.6:cloud (opencode) — Blocking issues found

Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

No security-relevant issues were identified in this PR. The changes are focused on concurrency plumbing (replacing a model-level cap with a provider-wide lens budget) and do not introduce new attack surface:

  • lenssem.go uses flock on permit files with 0o600 creation mode; no credential handling, no deserialization, no network I/O.
  • activeLensSem reads GADFLY_LENS_SEM_DIR/GADFLY_LENS_SEM_SIZE from env, but these are set exclusively by entrypoint.sh to controlled paths under $WORKDIR; no user input reaches them.
  • entrypoint.sh changes are purely about concurrency configuration; the deprecated provider_concurrency input is correctly ignored without creating authz side effects.
  • Docs / workflow changes are documentation-only and contain no secret exposure or misconfiguration.
🎯 Correctness — No material issues found

No material issues found.

I scrutinized the provider-wide lens budget implementation through the correctness lens. The cross-process permit pool (lenssem.go) correctly uses flock on lazily-created permit files: locks are per-open-file-description and auto-release on process death, which matches the design intent. The polling loop in acquire handles context cancellation without leaking permits.

The integration in main.go sizes the in-process fanout to sem.size when the pool is active, so a lone model can consume the full provider budget while extra goroutines block in sem.acquire() — this is the intended behavior. Fallback to in-process lensConcurrency() when the env vars are unset preserves standalone/test behavior.

The shell-side provider_lens_cap sanitizes the budget to >= 1 before passing it to the binary, and entrypoint.sh correctly seeds one shared directory per provider lane. activeLensSem gracefully degrades to nil on missing/invalid env values.

I re-derived the lensSemPollInterval (150 ms) against the stated lens-pass duration of "seconds to minutes": the average delay on a freed permit is ~75 ms, which is <0.2 % of a 60-second lens and <8 % of a 1-second lens — negligible overhead for avoiding IPC primitives.

🧹 Code cleanliness & maintainability — Minor issues

Minor issues

  • cmd/gadfly/main.go:100-103 — The comment on defaultLensConcurrency is stale: it says the limit applies "within a single model," but after this change GADFLY_LENS_CONCURRENCY is the provider-wide lens budget shared across all models in the lane. The constant is only used as a per-model fallback in standalone runs; the comment should match the new architecture or note the dual behavior so future readers don't assume the old per-model semantics.

  • entrypoint.sh:357 — Stale comment: "even models still waiting on their provider lane's concurrency cap" refers to the removed model-cap behavior. Models are no longer throttled by a concurrency cap; they all run at once and only lenses wait on the permit pool. Update to something like "even lenses still waiting on their provider lane's shared permit pool" to avoid misleading anyone tracing the flow.

Performance — No material issues found

Verdict: No material issues found

I reviewed the concurrency change through the performance lens and verified the new cross-process permit pool against the checked-out code.

The lenssem.go flock-based pool correctly bounds total lens passes in flight per provider: tryAcquire sweeps s.size permit files with non-blocking syscall.Flock, and acquire polls at 150 ms intervals. I traced runSpecialists in main.go and confirmed maxConcurrent is capped at sem.size when the shared pool is active, so in-flight goroutines can never exceed the budget.

The main performance tradeoff is intentional and correctly implemented:

  • Old: cap model processes × lensConcurrency lenses = multiplicative concurrency, with tail-stall when a model held its slot through its last lens.
  • New: All model processes in a lane start at once, but only budget lenses run concurrently across the entire lane. This eliminates the tail stall.

I checked for unbounded goroutine growth, memory leaks, N+1 query patterns, and hot-path regressions. The polling loop in acquire creates minor timer/syscall overhead, but the PR explicitly acknowledges this ("a coarse poll adds negligible latency") and it is dwarfed by LLM inference time. The flock files are auto-released on process death, preventing permit leaks.

🧯 Error handling & edge cases — Blocking issues found

Blocking issues found

  • cmd/gadfly/lenssem.go:76-78tryAcquire silently swallows os.OpenFile errors (continue on any err != nil). If the permit directory is missing, unwritable, or is a file instead of a directory, every permit file fails to open. tryAcquire then returns false, and acquire enters an infinite select loop sleeping 150 ms and retrying forever. The ctx passed from fanout.Run is derived from context.Background() and never cancels, so the goroutine never exits. It consumes a fanout slot and stalls every remaining lens for that model. Fix: return a sentinel error from tryAcquire when no file could be opened, and abort acquire (or at least surface the error) instead of polling indefinitely.

  • cmd/gadfly/lenssem.go:79-82tryAcquire treats any syscall.Flock error as “already locked” (f.Close(); continue). On filesystems that do not support flock (e.g. some NFS mounts, FUSE, or certain network shares), Flock returns EOPNOTSUPP or EINVAL. Because every file fails with the same hard error, tryAcquire again returns false forever, and acquire spins indefinitely with the same consequence as above. Fix: distinguish EAGAIN/EWOULDBLOCK (try the next file) from other errors; on a non-retryable error, abort and surface it.

Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.

<!-- gadfly-consensus --> ## 🪰 Gadfly review — consensus across 5 models **Verdict: Blocking issues found** · 11 findings (6 with multi-model agreement) | | Finding | Where | Models | Lens | |--|--|--|--|--| | 🔴 | Silent OpenFile errors cause infinite spin-poll in acquire, stalling review if permit directory is missing or unwritable | `cmd/gadfly/lenssem.go:72` | 4/5 | error-handling, maintainability | | 🟠 | Provider-wide lens permit acquire has no timeout; a broken/unusable permit directory (entrypoint.sh's unchecked 'mkdir -p') hangs every lens in the lane forever with no backstop unless GADFLY_PR_BUDGET_SECS is explicitly set | `cmd/gadfly/lenssem.go:54` | 3/5 | error-handling, performance | | 🟡 | Stale comment still references removed GADFLY_PROVIDER_CONCURRENCY instead of GADFLY_PROVIDER_LENS_CONCURRENCY | `cmd/gadfly/lens_concurrency_test.go:159` | 3/5 | maintainability | | 🟠 | maxConcurrent equals total budget allows one model to starve others in the same lane | `cmd/gadfly/main.go:268` | 2/5 | maintainability, performance | | 🟡 | Stale comment describes defaultLensConcurrency as per-model instead of provider-wide | `cmd/gadfly/main.go:100` | 2/5 | maintainability | | 🟡 | Stale comment references removed provider lane model cap | `entrypoint.sh:357` | 2/5 | maintainability | <details><summary>5 single-model findings (lower confidence)</summary> | | Finding | Where | Model | Lens | |--|--|--|--|--| | 🟠 | run_lane launches all models concurrently with no process/memory bound; only lens passes are throttled, causing N concurrent executor builds + /api/show HTTP lookups + resident processes per lane (regression vs old wait -n cap), worst at default budget=1 | `entrypoint.sh:340` | glm-5.2:cloud | performance | | 🟡 | GADFLY_LENS_SEM_DIR set but invalid/blank GADFLY_LENS_SEM_SIZE silently degrades to unthrottled (no budget) | `cmd/gadfly/lenssem.go:39` | glm-5.2:cloud | error-handling | | 🟡 | time.After leaks timer on context cancellation in acquire loop | `cmd/gadfly/lenssem.go:62` | kimi-k2.6:cloud | performance | | 🟡 | runSpecialists doc comment opening contradicts the new provider-wide-pool behavior added below it | `cmd/gadfly/main.go:248` | glm-5.2:cloud | maintainability | | 🟡 | Misleading 'cap is enforced within each lane' comment after model cap removal | `entrypoint.sh:397` | kimi-k2.6:cloud | maintainability | </details> <details><summary>Per-model detail</summary> <details><summary><b>claude-code/sonnet</b> (claude-code) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> ## Security Review **VERDICT: No material issues found** This PR is an internal concurrency refactor (flock-based permit pool replacing the two-level model/lens cap). I read the full new `lenssem.go`, its test file, and traced the permit-pool wiring through `entrypoint.sh` → `run.sh` → the binary to check for injection, unsafe file handling, and secret leakage. Things I specifically checked and found sound: - **Permit file paths aren't attacker-influenced.** `sem_dir` is built from the provider name (`entrypoint.sh:319`), which comes from `GADFLY_MODELS`/`GADFLY_PROVIDER` — trusted consumer/workflow config, not PR body/title content. It's sanitized with the same `tr -c '[:alnum:]._-' '_'` pattern already used for `status_file_for`/`findings_file_for` elsewhere in this file, so no path traversal via provider name. - **Permit files carry no data** (`lenssem.go:75`, opened `O_RDWR|O_CREATE`, mode `0o600`) — just used for `flock`, so there's no secret-leakage or content-injection surface even if another local process could read them. - **Env plumbing is a straight pass-through**, not shell-evaluated: `GADFLY_LENS_SEM_DIR`/`GADFLY_LENS_SEM_SIZE` flow entrypoint.sh → `run.sh`'s inherited environment → the binary's `os.Getenv` (verified `run.sh:180-191` and `lenssem.go:40-48`); `envInt` (`main.go:411`) safely rejects non-numeric/non-positive input instead of doing anything unsafe with it. - **No new command construction from untrusted input** — all shell interpolations of `$p`/`$m` in the new/changed `entrypoint.sh` code are either sanitized filenames or values drawn from trusted config, consistent with pre-existing patterns in the same file (`status_file_for`, `findings_file_for`). - The error surfaced when a permit acquire is canceled (`main.go`: `"⚠️ This reviewer did not run: %v"`) only ever carries `ctx.Err()` (`context.Canceled`/`DeadlineExceeded`), not any internal state or secrets. One thing I considered but didn't report as a finding: `tryAcquire` opens `permit.N` without `O_EXCL`, which in a shared, world-writable `/tmp` could theoretically be a symlink-following TOCTOU if a malicious co-tenant could pre-plant a symlink at the predictable `WORKDIR`-derived path. I checked this isn't new exposure — `STATUS_DIR`/`FINDINGS_DIR` under the same `WORKDIR` already followed this exact predictable-path pattern before this PR, and the runtime is an isolated per-job CI container (not a shared multi-tenant host), so this doesn't rise to a reportable finding for this diff. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> All confined to lenssem.go and main.go, consistent with the diff. I've verified the core concurrency mechanism (Go flock permit pool, bash lane orchestration, and their interaction) end-to-end and found the logic sound: permit acquisition happens before the per-lens timeout starts (so queue wait isn't counted against `GADFLY_TIMEOUT_SECS`), `sem.size` correctly bounds the in-process fanout so a lone model can claim the whole provider budget while still yielding to siblings, `provider_of`/`modelProvider()` use identical segment-splitting so bash and Go agree on lane keys, and no stale references to the removed `GADFLY_PROVIDER_CONCURRENCY`/`provider_cap` remain outside comments. VERDICT: No material issues found The concurrency redesign (flock-based cross-process permit pool in `lenssem.go`, its wiring into `runSpecialists` in `main.go`, and the bash-side `run_lane`/`provider_lens_cap` changes in `entrypoint.sh`) is logically consistent. I traced the full flow: `entrypoint.sh` computes `budget` once per lane and passes `sem_dir`/`sem_size` to every model's `review_one` → `run.sh` inherits `GADFLY_LENS_SEM_DIR`/`GADFLY_LENS_SEM_SIZE` as ambient env (the explicit-var-prefix form doesn't strip pre-existing exported vars, so they correctly reach the `gadfly` binary even though `run.sh` doesn't re-list them) → `activeLensSem()`/`lensConcurrency()` resolve them the same way `provider_of`/`modelProvider()` already agreed on before this PR. Permit acquisition happens before `reviewWithSpecialist`'s own timeout context starts, so queueing time isn't charged against `GADFLY_TIMEOUT_SECS`, and `defer release()` is correctly skipped on the error path (function already returned) and correctly reached on success. The new `lenssem_test.go` exercises exhaustion, blocking+cancel, and the peak-never-exceeds-budget invariant in a way that matches the production semantics (flock is per open-file-description, so same-process double-opens validly model cross-process contention). No stale references to the removed `GADFLY_PROVIDER_CONCURRENCY`/`provider_cap` remain in code paths (only in comments/deprecation notes, as intended). </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> All other repo mentions of `GADFLY_PROVIDER_CONCURRENCY` explicitly frame it as removed/historical (CLAUDE.md, README, entrypoint.sh, examples, review-reusable.yml). The one at `lens_concurrency_test.go:159` is indeed the sole remaining spot that presents it as a still-live rule ("same lane rule entrypoint.sh uses for"), confirming the draft's claim exactly, including that it's the only outlier. VERDICT: Minor issues - `cmd/gadfly/lens_concurrency_test.go:159` — The comment on `TestLensConcurrency` says the per-provider lane matching exercised by the test is "the same lane rule entrypoint.sh uses for `GADFLY_PROVIDER_CONCURRENCY`". That variable's per-provider model-cap concept is exactly what this PR removes: `entrypoint.sh:207` now says the `GADFLY_PROVIDER_CONCURRENCY` model cap was "now removed", and the lane resolution the test actually exercises (`lensConcurrency()`/`providerOverride()` in `main.go:321-328`) is keyed on `GADFLY_PROVIDER_LENS_CONCURRENCY` only. Every other file touched by this PR (main.go, entrypoint.sh, README, CLAUDE.md, examples, review-reusable.yml) consistently frames `GADFLY_PROVIDER_CONCURRENCY` as removed/historical, but this pre-existing test comment still reads as if it's the live rule — it's the only remaining place in the repo doing so. Test correctness is unaffected (the test itself only sets/reads `GADFLY_PROVIDER_LENS_CONCURRENCY`); this is a stale-comment/doc-drift issue of the kind CLAUDE.md's own maintenance rule calls out. Fix: reword to reference `GADFLY_PROVIDER_LENS_CONCURRENCY`. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **VERDICT: No material issues found** I reviewed `cmd/gadfly/lenssem.go`, the `runSpecialists`/`lensConcurrency` changes in `cmd/gadfly/main.go`, and the `entrypoint.sh` `run_lane`/`review_one` rework, since this PR is entirely about concurrency/throttling. - The flock permit pool (`lenssem.go:72-86`) uses a 150 ms poll loop (`lenssem.go:33,62`) rather than a blocking primitive. Each blocked goroutine re-sweeps up to `size` permit files per poll (open+flock+maybe-close). With the documented default budget of 1 this is a single syscall attempt per 150 ms per waiter; even at larger configured budgets with many models in a lane, the sweep is O(budget) and the poll interval is coarse relative to lens durations ("many seconds to minutes," per the file's own comment at `lenssem.go:31-32`). I verified `tryAcquire`/`acquire` do exactly this and confirmed `runSpecialists` (`main.go:268-297`) only calls `sem.acquire` once per lens (not in a hot loop), so this doesn't compound — a documented, bounded tradeoff, not a hot-path regression. - `entrypoint.sh` (`entrypoint.sh:329-339`) removed the `wait -n`-based launch throttle, so `run_lane` now spawns every model in a provider lane as a full OS process concurrently, instead of the old default-1 sequential launch. This does raise peak concurrent process/memory usage during model-process startup (repo read, diff embedding, executus setup) for lanes with many models — but it's the explicit, documented purpose of this PR ("every model in the lane runs concurrently… the only throttle is a lens budget," per the README/CLAUDE.md updates), not an accidental side effect, and the actual expensive work (LLM calls) stays gated by the shared permit pool. Not flagging as a regression since it's the intended design, but noting it since it's the one place total resource concurrency is now uncapped where it previously wasn't. - `runSpecialists` (`main.go:268-275`) sizes `MaxConcurrent` to `sem.size` when the pool is active, so a lone model's fanout can use the whole provider budget as intended; confirmed `fanout.Run` still returns in input order and per-lens timeouts (`reviewTimeout()` in `reviewWithSpecialist`, `main.go:355-357`) are unaffected by permit queueing time — no busy-work or leaked goroutines on cancellation (`main.go:290-297` returns a no-op release and marks the lens finished). Nothing here amounts to an N+1, unbounded growth, or hot-loop regression within the performance lens — the design is a deliberate trade of cross-process simplicity (flock polling) for a small, bounded, well-documented overhead. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> All corroborated — no test covers a permanently-unusable permit directory. The draft's central finding holds up completely against the code. ## VERDICT: Minor issues - **`cmd/gadfly/lenssem.go:54-65` (`lensSem.acquire`) + `cmd/gadfly/main.go:274,291` + `entrypoint.sh:337-338`** — the provider-wide permit acquire has no bound independent of the permit directory being usable, and the directory's creation error is swallowed. - `runSpecialists` calls `fanout.Run(context.Background(), ...)` (`main.go:274`), and the closure calls `sem.acquire(ctx)` with that same undeadlined context (`main.go:291`). `acquire` only returns early on `ctx.Done()` (`lenssem.go:59-63`); with an undeadlined background context that never fires, so `acquire` polls `tryAcquire` every 150ms forever if it can never lock a permit. - `tryAcquire` (`lenssem.go:72-86`) tries `os.OpenFile(path, O_RDWR|O_CREATE, 0o600)` on each `permit.<i>` file inside `s.dir`; on any error it just `continue`s to the next index, with no error surfaced. - `entrypoint.sh:338` does `mkdir -p "$sem_dir"` with **no error check**, then unconditionally passes that (possibly nonexistent/unusable) dir to every model via `GADFLY_LENS_SEM_DIR` (`entrypoint.sh:277`). - Net effect: if the permit directory can't be created or written (disk full, permission issue, weird `WORKDIR`), every lens for every model in that provider's lane calls `activeLensSem()` → non-nil (`GADFLY_LENS_SEM_DIR`/`_SIZE` both set) → `tryAcquire` fails every sweep → `acquire` spins forever with **zero timeout**, since the per-lens deadline (`GADFLY_TIMEOUT_SECS`, via `reviewTimeout()` inside `reviewWithSpecialist`, `main.go:356`) is created only *after* a permit is acquired (`main.go:290-299`) — confirmed `reviewWithSpecialist` builds its own independent `context.WithTimeout(context.Background(), ...)`, unrelated to the fanout context `acquire` is blocked on. The only remaining backstop is `GADFLY_PR_BUDGET_SECS`, which defaults to `0` (disabled, `entrypoint.sh:379`). This directly undermines the documented invariant "a slow lens can't starve the others" — a broken permit pool now hangs the whole lane with no built-in protection. - This failure mode also isn't covered by `lenssem_test.go`: all tests use a real `t.TempDir()` that always succeeds, so the "acquire on a permanently-unusable pool" case is untested. - Suggested fix: give `sem.acquire` its own bounded fallback (e.g., treat repeated `tryAcquire` failures for reasons other than "locked" — i.e., open errors — as fatal after a short grace period, or have `entrypoint.sh` verify `mkdir -p "$sem_dir"` succeeded and fall back to unsetting `GADFLY_LENS_SEM_DIR`/leaving the model on in-process concurrency instead of a broken shared pool). Everything else checked out: `activeLensSem`'s `n < 1` guard correctly makes a zero/invalid `GADFLY_LENS_SEM_SIZE` inert (`lenssem.go:44-47`), `envInt` rejects non-positive values (`main.go:411-421`) and `entrypoint.sh:332` guards a non-numeric/sub-1 `budget` back to `1`, and `statusWriter.set` (`status.go:75-...`) does a simple by-name lens update with no index/nil assumptions. </details> </details> <details><summary><b>glm-5.2:cloud</b> (ollama-cloud) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> ## Verdict: No material issues found I reviewed the change through the **security** lens (authn/authz, injection, path traversal, secret leakage, unsafe handling of untrusted data, insecure defaults) and verified the tainted-input paths against the checked-out code. What I checked and why it's clean: - **Permit-pool path construction (`entrypoint.sh:337`, `cmd/gadfly/lenssem.go:74`).** The sem dir is `${LENS_SEM_ROOT}/<sanitized provider>`, where the provider key is run through `tr -c '[:alnum:]._-' '_'` — so a model spec/provider string can't inject `../` or shell metacharacters into the directory path. `LENS_SEM_ROOT` is `${WORKDIR}/lenssem` with `WORKDIR` defaulting to `/tmp/gadfly` (`entrypoint.sh:63`), set by the container, not by PR-controlled input. Permit filenames are `fmt.Sprintf("permit.%d", i)` with `i < size` — no traversal possible. - **File creation mode (`cmd/gadfly/lenssem.go:75`).** Permit files are opened `O_RDWR|O_CREATE` with mode `0o600` (owner-only read/write) — no world-readable/writable default, and the dir is per-run under a private WORKDIR, so no cross-tenant leak/collision surface. - **Env-var sourcing (`cmd/gadfly/lenssem.go:39-48`).** `GADFLY_LENS_SEM_DIR`/`GADFLY_LENS_SEM_SIZE` are set by `entrypoint.sh`/`run.sh` from container-derived values, not from PR title/body/comment text (those flow into the review prompt, not these vars). `envInt` (`main.go:411`) rejects non-positive/unparseable sizes, so a malformed size degrades safely to "no pool" rather than a zero-size or huge allocation. No injection via these inputs. - **flock descriptor handling.** `syscall.Flock(int(f.Fd()), ...)` operates on the already-opened fd; the release path closes the `*os.File`, dropping the lock. No fd leak / privilege surface introduced. The lock is advisory (LOCK_EX|LOCK_NB), consistent with advisory-only review behavior — no new blocking/exit-code enforcement (a security-relevant invariant for a review bot that must not block merges). - **`providerOverride` (`main.go:337-348`).** Parses the `provider=N,...` map with `strings.Cut`/`strconv.Atoi` and validates `n > 0`; untrusted map entries can't cause a negative/zero fanout or panic. No command/SQL/template injection — pure in-process parsing. No secret/credential values pass through any new code path, and no new untrusted-data sink was introduced. I could not identify any material security issue within this lens. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> Confirmed. The design is correct. VERDICT: No material issues found - I verified the central correctness claim — that the new design bounds total in-flight lens passes per provider to the budget N — by reading `cmd/gadfly/main.go:259-318` and `cmd/gadfly/lenssem.go`. entrypoint.sh seeds one shared `sem_dir` per lane with `budget=N` for every model in the lane (`entrypoint.sh` run_lane / review_one), each model process sets `maxConcurrent = sem.size` (main.go:268-272), and every lens goroutine blocks in `sem.acquire` before running (main.go:290-297). `tryAcquire` (lenssem.go) sweeps `permit.0..size-1` with `O_RDWR|O_CREATE` + `LOCK_EX|LOCK_NB`; flock exclusion is per open-file-description, so cross-process mutual exclusion on the shared N files genuinely caps concurrent holders at N. Correct. - Default-budget boundary re-derived: `defaultLensConcurrency = 1` (main.go:103) and entrypoint falls back to `GADFLY_LENS_CONCURRENCY:-1`. With budget 1, at most one lens pass is in flight per provider across all its models — consistent with the documented "default keeps a provider fully sequential." Equivalent to the prior default behavior (one model at a time × one lens at a time = 1 in flight). Correct. - Context cancellation path: `runSpecialists` passes `context.Background()` into `fanout.Run` (main.go:274), so the `ctx` a lens sees is effectively un-cancellable; `sem.acquire` will poll until a permit frees rather than surfacing `ctx.Err()`. That is acceptable for the design (lenses have their own per-lens timeout inside `reviewWithSpecialist` at main.go:356) and the cancellation branch is still exercised by tests via a context with deadline. No correctness defect. - The `acquire`-on-cancellation returns a no-op release (lenssem.go acquire), so no permit leak on ctx-done. Verified. - Stale-pool cleanup: entrypoint.sh `rm -rf "$LENS_SEM_ROOT"` before seeding prevents a reused WORKDIR from carrying over old permit files. Verified in the diff. Nothing in the correctness lane is materially wrong. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Reviewed</summary> I have enough to verify all three findings. **Finding 1** (main.go:248): Confirmed. Lines 248-249 still read "up to GADFLY_LENS_CONCURRENCY lenses run concurrently (the default of 1 keeps the suite sequential, exactly as before)", while the appended paragraph (254-258) and the code (270-272, `maxConcurrent = sem.size`) make the active ceiling the provider-wide pool size. Minor drift. Keep as small. **Finding 2** (lens_concurrency_test.go:159): Confirmed. Line 159 references "the same lane rule entrypoint.sh uses for GADFLY_PROVIDER_CONCURRENCY", a var now removed/ignored. `providerOverride`/`lensConcurrency` now key on `GADFLY_PROVIDER_LENS_CONCURRENCY` (main.go:320-336). Stale reference. Keep as trivial. **Finding 3** (lenssem.go:67): Not confirmed. `tryAcquire` iterates `i = 0..size-1` and opens each `permit.{i}` with `O_CREAT`, so the full `permit.0..size-1` set is created — not "files named by first successful index." The "no seeding step beyond mkdir" comment is accurate: entrypoint guarantees only the dir; files appear lazily on first open. Cross-process recreation of the same-named files is exactly the lazy-creation behavior the comment describes, not an oversell. Drop. </details> <details><summary><b>⚡ Performance</b> — Minor issues</summary> Both findings are verified against the actual code. Let me confirm the key claims: **Finding 1** — `run_lane` (entrypoint.sh:329-344) loops over all models in `mine` and launches `review_one … &` for each with no inflight cap, then `wait`. Only the lens permit pool (`sem_dir`/`budget`) throttles lens passes. `newReviewExecutor` (main.go:187) calls `resolveContextTokens` (executus.go:239, 351-370) at executor-build time — a synchronous `/api/show` HTTP call with a 5s timeout — before `runSpecialists` (where `sem.acquire` happens at main.go:291). So N models in a lane spawn N concurrent processes, each building an executor + doing an HTTP lookup before any lens throttle. Confirmed. **Finding 2** — `acquire` (lenssem.go:54-65) polls every 150ms via `tryAcquire` (72-85), which opens + flocks + closes each of `size` permit files. Each blocked goroutine does this independently → O(blocked × budget) syscalls per tick. Confirmed (the draft's exact `(M·L−B)` formula slightly overstates it since `fanout.MaxConcurrent` caps concurrent in-process goroutines at the budget, but the core scaling claim and "negligible at small budgets" conclusion hold — if anything the actual cost is tighter than stated). **Verdict: Minor issues** - **`entrypoint.sh:340-343` — unbounded model-process fanout per lane (resource regression).** `run_lane` now launches *every* model in the lane at once (`review_one … &` with no `wait -n` cap) and only throttles lens *passes* via the flock pool. The lens budget bounds concurrent LLM calls but places **no bound on resident model processes**. Each model is a full Go binary that, at startup, builds the run executor and does a synchronous context-token HTTP lookup (`resolveContextTokens` → `/api/show`, up to `contextTokenLookupTimeout = 5s`, `executus.go:351-370`) *before* any lens permit is acquired (`main.go:291`). So with the default budget of 1 and, say, 8 models in one provider lane, you get 8 concurrent full processes — 8 concurrent `/api/show` HTTP calls, 8 resident executors/tool-registries/repoFS trees, 8 agent-loop stacks — even though only one lens pass runs at a time. The old `cap`/`wait -n` path bounded exactly this. The PR's intent is to run all models concurrently for LLM-load sharing, but the loss of *any* process/memory bound per lane is a real oversubscription regression for lanes with many models, worst at the default budget. Suggestion: document that model count per lane should stay modest, or keep a soft model-process cap independent of the lens budget so a 20-model lane with budget 1 doesn't spawn 20 resident binaries. - **`cmd/gadfly/lenssem.go:54-65,72-85` — polling sweep cost scales with `blocked_goroutines × size`.** Every lens goroutine blocked in `acquire` re-runs `tryAcquire` every 150ms, and each sweep does `os.OpenFile(O_CREATE)` + `syscall.Flock(LOCK_NB)` + `Close()` on all `size` permit files. For blocked goroutines each touching B files per tick → `blocked × B` open/flock/close syscalls per 150ms. For typical small budgets (1-3) and handfuls of lenses this is negligible (~10² syscalls/sec), so flagging only as a minor note, not a blocker: it stays well-behaved at documented scales but is O(blocked × budget) and would get loud if someone set a large budget with many queued lenses. No change required; worth a comment noting the scaling if large budgets are ever supported. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> **Verdict: Minor issues** Through the error-handling / edge-case lens, the new `lensSem` has one material gap plus a smaller robustness nit. The happy paths and the panic/cancel handling are sound. ### Findings - **`cmd/gadfly/lenssem.go:72-85` — broken/nonexistent pool dir → silent infinite poll (no fallback, no timeout).** `tryAcquire` does `os.OpenFile(...)` per permit and on *any* error just `continue`s; if every open fails (the `GADFLY_LENS_SEM_DIR` doesn't exist, is unreadable, was removed mid-run, or the `mkdir -p` in `run_lane` failed), `tryAcquire` always returns `false`. `acquire` then loops `tryAcquire` + `time.After(150ms)` until `ctx.Done()`. The ctx handed to the fn is the one `fanout.Run` derives from `context.Background()` at `main.go:274` — i.e. **never cancelled** (the per-lens timeout only starts inside `reviewWithSpecialist` at `main.go:356`, *after* the permit is acquired via `sem.acquire` at `main.go:291`). Verified: the `WithTimeout` is created at `main.go:356`, reached only after `sem.acquire` returns and `defer release()` is registered (`main.go:296`), so the queue wait has no deadline. So a misconfigured/stale sem dir makes every lens in that process spin forever at ~7 Hz, never running, never erroring — the lens hangs at "queued" on the board and the process never completes. (In normal operation `entrypoint.sh:338` does `mkdir -p "$sem_dir"`, so this requires the dir to be absent/broken/unreadable at runtime; it's a robustness gap, not a happy-path bug.) Suggested fix: if `OpenFile` fails for *all* `size` files in one sweep, either fail fast (return an error so the lens surfaces as "did not run") or treat a permanently-unopenable dir as "pool inert" and fall back to running unthrottled; at minimum, distinguish "all files errored" from "all files locked" so a broken dir isn't indistinguishable from a busy pool. - **`cmd/gadfly/lenssem.go:39-49` + `entrypoint.sh:332,338,277` — size/budget mismatch between shell and binary is not reconciled.** `run_lane` clamps `budget` to ≥1 (`entrypoint.sh:332`) and passes it as `GADFLY_LENS_SEM_SIZE` (`entrypoint.sh:277`), but `activeLensSem` independently re-reads `GADFLY_LENS_SEM_SIZE` via `envInt("GADFLY_LENS_SEM_SIZE", 0)` (which returns the default `0` for blank/garbage/≤0, `main.go:411-421`), then returns `nil` when `n < 1` (`lenssem.go:45-47`). When `sem == nil`, `main.go:270-272` skips sizing the fanout to the pool and no permit is acquired, so the binary silently runs **unthrottled** by the cross-process budget. The two paths agree today (entrypoint always sets a valid size), but the binary's pool size is sourced from an env var that any wrapper could rewrite; a stale/blank/invalid `GADFLY_LENS_SEM_SIZE` with a non-empty `GADFLY_LENS_SEM_DIR` degrades to "no throttle" rather than failing. Low likelihood, but it's an unguarded edge. Suggested fix: when `GADFLY_LENS_SEM_DIR` is set but `GADFLY_LENS_SEM_SIZE` is invalid, log a warning or fail the lens rather than silently dropping the budget. ### Verified safe (not reported) - Panic-while-holding-permit is handled: `defer release()` is registered after `defer recover()` (`main.go:281,296`), so on panic `release()` runs first (closes the fd, dropping the flock) and then `recover()` catches the value — no permit leak on panic. - `acquire` cancellation path returns a no-op release + `ctx.Err()`, and the caller marks the lens finished + errored (`main.go:292-294`) — no leaked permit on cancel. - `envInt`/`providerOverride` both reject ≤0 and non-numeric, so a bad `GADFLY_LENS_SEM_SIZE`/provider map can't produce a negative or zero `size` that would make `tryAcquire`'s loop a no-op or divide-by-zero. </details> </details> <details><summary><b>kimi-k2.6:cloud</b> (ollama-cloud) — Blocking issues found</summary> **Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> No material issues found **`cmd/gadfly/lenssem.go:40,74-75` — Path traversal via unsanitized `GADFLY_LENS_SEM_DIR`** `activeLensSem` reads `GADFLY_LENS_SEM_DIR` from the environment and `tryAcquire` feeds it directly into `filepath.Join` without validation. An attacker who can set this variable (e.g. in a standalone/local run or a compromised environment) can inject `../` sequences, causing the binary to create and acquire exclusive `flock` locks on files outside the intended semaphore directory. While `entrypoint.sh` sets the value safely, the binary itself should not trust the path. **Fix:** validate that the path is absolute and optionally enforce it sits under an expected base directory (e.g. `/tmp/gadfly/lenssem`); reject or sanitize any `..` segments. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **No material issues found** I investigated the core correctness of the new flock-based permit pool (`cmd/gadfly/lenssem.go`), its integration into `runSpecialists` (`cmd/gadfly/main.go`), and the entrypoint.sh orchestration. After thorough verification: - **`tryAcquire` / `acquire` logic**: The non-blocking sweep over permit files with `flock(LOCK_EX|LOCK_NB)` is correct. On success, the returned release func closes the fd, dropping the kernel-held flock. On cancellation, a no-op release is returned — no permit is leaked. - **Process-death safety**: `flock` is tied to the open file description; the kernel automatically releases it on process exit. This matches the PR's stated invariant and is semantically correct. - **Cross-process coordination**: Entrypoint.sh seeds the per-lane directory and passes `GADFLY_LENS_SEM_DIR` + `GADFLY_LENS_SEM_SIZE`. The binary's `activeLensSem()` reads these and falls back to in-process throttling when unset. Verified that `envInt` correctly handles zero/negative/missing values by returning nil (safe fallback). - **Fanout sizing**: Setting `maxConcurrent = sem.size` when the pool is active ensures a lone model can consume the full provider budget, while extra goroutines simply block in `sem.acquire()` until a permit frees. The interaction with `fanout.Run` is sound — permits are acquired inside the worker, so `MaxConcurrent` can safely exceed the actual pool size. - **Panic safety**: The `defer release()` is set after successful acquisition, and `reviewWithSpecialist` has its own internal timeout. Even if a panic occurs after permit acquisition, stack unwinding will call the deferred release. Verified the ordering in `main.go:290-297`. No logic bugs, incorrect behavior, or semantic/domain errors were identified. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> **Minor issues** - **`entrypoint.sh:357`** — Stale comment referencing removed model cap: "even models still waiting on their **provider lane's concurrency cap**" but the PR removed the per-provider model cap (`GADFLY_PROVIDER_CONCURRENCY`). Under the new design every model in a lane starts immediately; the only thing that could delay a model is the lens permit pool, not a model slot. This will mislead future maintainers trying to understand lane behavior. **Fix:** Update to something like "even models whose lenses are waiting on the shared provider-wide lens budget." - **`entrypoint.sh:397`** — Ambiguous/misleading comment after cap removal: "**cap is enforced within each lane**" is no longer true — there is no model cap inside a lane. The lens budget is enforced by the binary's flock pool, not by `run_lane`. **Fix:** Rephrase to "lens budget is shared across all models in each lane" or similar. - **`cmd/gadfly/main.go:100-103`** — Stale doc comment on `defaultLensConcurrency`: it still says "how many specialist lenses run at once **within a single model**." Under the new design this value seeds the *provider-wide* budget when entrypoint.sh sets the shared pool, and a lone model can use the whole budget. **Fix:** Update the comment to reflect provider-wide semantics, e.g. "defaultLensConcurrency is the provider-wide lens budget when running under entrypoint.sh (shared across all models in the lane); standalone it caps in-process fanout." - **`cmd/gadfly/lenssem.go:76`** — Silent swallowing of `os.OpenFile` errors in `tryAcquire()`: if the permit directory is missing, unwritable, or the process hits `EMFILE`, every sweep fails but the code simply `continue`s with no diagnostic. A misconfigured pool will cause `acquire()` to spin forever (retrying every 150 ms) and the lens will appear permanently queued on the status board, with no clue in logs about why. **Fix:** At minimum log the first error encountered per sweep, or surface a distinct error when no permit files can be opened so operators can see the pool is broken. </details> <details><summary><b>⚡ Performance</b> — Minor issues</summary> **Verdict: Minor issues** * **`cmd/gadfly/main.go:268-272` — Model starvation within a shared lane.** When `sem != nil`, `maxConcurrent` is set to `sem.size` (the total cross-process lens budget). `fanout.Run` eagerly refills a finished slot with the next queued specialist, so a model with more lenses than the budget tends to immediately re-acquire the freed permit. Other models in the same lane are blocked in `sem.acquire` polling every 150 ms and usually lose the race. The result: lenses across models do not interleave well when one model has significantly more specialists than the budget, effectively serialising the lane again. **Suggested fix:** either size the in-process fanout smaller than the total budget (e.g. `max(1, sem.size/2)` or simply `lensConcurrency()` without the override) so that no single model can greedily refill all permits, or document that the shared budget works best when models have comparable lens counts. * **`cmd/gadfly/lenssem.go:62` — `time.After` timer leak on context cancellation.** `acquire` uses `time.After(lensSemPollInterval)` inside a `select`. If the caller’s context is cancelled before the timer fires, the timer is not stopped and lives until it fires, creating garbage on every cancelled wait. With many blocked models this is minor but measurable. **Suggested fix:** use `time.NewTimer` + `timer.Stop()` in the `ctx.Done()` branch. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Blocking issues found</summary> **Blocking issues found** - **`cmd/gadfly/lenssem.go:76`** – `tryAcquire` silently swallows `os.OpenFile` errors and `acquire` will spin-poll forever when the permit directory is missing or unwritable. If `GADFLY_LENS_SEM_DIR` points to a non-existent path (or permissions change after `entrypoint.sh` creates it), every call to `tryAcquire` fails on all permit files, returns `(nil, false)`, and `acquire` loops every 150 ms doing useless syscalls. Because `fanout.Run` is driven by `context.Background()`, that context never cancels on its own, so the goroutine hangs until the outer job timeout (or indefinitely in local/test misconfigurations). The binary should distinguish transient lock contention (`EWOULDBLOCK`) from permanent file-system errors and bail out fast on the latter. **Suggested fix:** in `tryAcquire`, propagate non-EWOULDBLOCK errors up to `acquire`, which should return the error immediately rather than retrying. *(Verified by reading `lenssem.go` and tracing the call path from `main.go:290` through `acquire` / `tryAcquire`.)* </details> </details> <details><summary><b>opencode/glm-5.2:cloud</b> (opencode) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> I'll examine the new `lenssem.go` and related code for security issues. I've reviewed the change through the security lens. The new concurrency mechanism touches file I/O (`os.OpenFile` with `O_CREATE`, `flock`), env-var-driven paths, and bash path construction — all worth scrutinizing for injection/path-traversal/symlink/permission issues. Verification performed: - Read `cmd/gadfly/lenssem.go` fully and `envInt` (main.go:411): `GADFLY_LENS_SEM_SIZE` is parsed to int and rejected if `<1`, and the permit filename is `fmt.Sprintf("permit.%d", i)` with `i` bounded by `size` — no path component from env reaches the filename; only the directory does. - Read `entrypoint.sh:252-339`: `LENS_SEM_ROOT` is a fixed path `${WORKDIR}/lenssem` (operator-controlled, not PR-tainted), cleared once and recreated per-lane. `sem_dir` derives from provider `p` which comes from `provider_of` of `GADFLY_MODELS` — operator config, not PR content — and is sanitized via `tr -c '[:alnum:]._-' '_'`. `provider_lens_cap` compares and echoes values without `eval`. - Permit files created with `0o600` and `O_RDWR|O_CREATE`; flock is non-blocking (`LOCK_NB`), no blocking syscall that could hang on a hostile file. - All inputs feeding `GADFLY_LENS_SEM_DIR`/`GADFLY_LENS_SEM_SIZE`/`GADFLY_PROVIDER_LENS_CONCURRENCY` flow from workflow `inputs`/owner `vars` (operator-configured), not from the PR diff or PR metadata. No tainted PR input reaches the permit-pool paths. No symlink-race concern either: the directory is under the runner's private `WORKDIR`, the `rm -rf` + `mkdir -p` happens at setup before any model process starts, and a PR author has no write access to that filesystem. ## VERDICT: No material issues found - No injection/path-traversal: the only env-controlled path segment is the directory; the permit filename is a bounded integer format string. The directory is operator-configured (`WORKDIR/lenssem`), not PR-tainted, and the provider subdirectory is sanitized. - No unsafe deserialization or credential leakage: the change only adds flock-based file locking and env plumbing; no secrets are read, logged, or forwarded (the `GADFLY_LENS_SEM_*` envs carry only a directory path and an integer). - Permissions are appropriately restrictive (`0o600` on permit files). - No PR-controlled input reaches the semaphore paths or the bash `provider_lens_cap`/`run_lane` logic. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> Now let me think about correctness. Key observations: 1. **Lens budget vs. model fanout sizing**: `runSpecialists` sets `MaxConcurrent = sem.size` when sem is active. With multiple models in a lane, each model gets `sem.size` goroutines all competing for the same `sem.size` permits across processes. That's the intent. 2. **The pool semantics**: `tryAcquire` opens `permit.<i>` files and flocks them. There's a subtle concern — file creation is shared across processes. Two processes both call `OpenFile(O_RDWR|O_CREATE)` on `permit.0`; one locks it, the other gets `EWOULDBLOCK`. The second then tries `permit.1`. That's correct. 3. **Concern: file descriptor leak / partial acquire in `tryAcquire`**: if `OpenFile` succeeds but `Flock` fails, `f.Close()` is called. Good. No leak. 4. **The release func** — `func() { f.Close() }`. Closing the fd releases the flock. Correct. 5. **Potential correctness issue**: In `acquire`, when ctx is cancelled, returns `func(){}`. Good — no permit held. 6. **Status board race**: if `sem.acquire` fails (ctx done), the code sets `sw.set(sp.Name, lensFinished, "", true)` and returns an errored result. But note: this is `errored: true` and `out: "⚠️ This reviewer did not run: ..."`. Verdict is `verdictUnknown`. That's fine. 7. **Concern about budget exceeding**: With size N pool, multiple models each spawn N goroutines (MaxConcurrent = sem.size). All goroutines across all models compete for N permits. Max in-flight = N. ✓ Let me check `fanout.Run` semantics to confirm `MaxConcurrent` controls goroutine count, not blocking: Now let me think through correctness carefully: 1. **Key concern**: `MaxConcurrent = sem.size`. With `sem.size` permits across multiple model processes, each process sets its in-process fanout to `sem.size`. So if 3 models each spawn 3 goroutines (size=3), you have 9 goroutines all trying to acquire from a pool of 3 permits. Max in-flight = 3. ✓ Correct. 2. **But** — within a single model process (no entrypoint pool, local runs): `sem == nil`, `maxConcurrent = lensConcurrency()`. That's the original behavior. ✓ 3. **Subtle concern**: When sem is set, `lensConcurrency()` (the provider override / scalar) is no longer applied to the in-process fanout — only `sem.size` is. Both come from the same source (`GADFLY_PROVIDER_LENS_CONCURRENCY` → entrypoint `provider_lens_cap` → `sem_size`; and `lensConcurrency()` → `GADFLY_PROVIDER_LENS_CONCURRENCY`/`GADFLY_LENS_CONCURRENCY`). So they should agree. But there's a subtle bug: `envInt` (used by the Go side reading `GADFLY_LENS_SEM_SIZE`) and bash's `provider_lens_cap` parse the same value, so they agree. 4. **Important issue**: When `sem != nil`, `maxConcurrent = sem.size`. But the `lensConcurrency()` per-provider override is NOT consulted by the binary when sem is set. That's fine because entrypoint already consulted it. BUT — what if the user sets `GADFLY_PROVIDER_LENS_CONCURRENCY` directly in the binary's env without entrypoint? Then `lensConcurrency()` would return the right value but `sem.size` (from `GADFLY_LENS_SEM_SIZE`) might differ. That's a niche case not applicable here (entrypoint always sets both consistently). Not a real bug. 5. **More important: the `acquire` poll loop creates `time.After` channels that leak until fired** — minor, but with long waits it accumulates timers. Actually each iteration creates a new timer; the previous one is GC'd. Not a real bug for this scale. 6. **Real concern**: `tryAcquire` opens files with `O_CREAT` — but it never cleans up the permit files. On a reused WORKDIR, stale permit files accumulate but they're just empty files; flock state is per-open-FD, so stale files don't hold locks. Plus entrypoint does `rm -rf "$LENS_SEM_ROOT"` up front. ✓ 7. **Concern about flock behavior on close vs. fd reuse**: `f.Close()` releases the flock. Standard. ✓ 8. **Potential correctness issue I want to verify**: in `runSpecialists`, when `sem.acquire` returns an error (ctx cancelled), the code sets `sw.set(sp.Name, lensFinished, "", true)` and returns `errored: true`. But the panic recovery `defer` runs AFTER — wait, the defer is registered before the `sem.acquire` call. If `sem.acquire` errors, we return; the defer runs `recover()` which is nil, so no-op. ✓ 9. **Subtle: the `defer release()` is registered before `sw.set(sp.Name, lensRunning, ...)`. If `reviewWithSpecialist` panics, the defer recovers, sets res, sets sw finished. Then the `defer release()` runs (LIFO), releasing the permit. ✓ Correct order. Let me double check the `fanout.Run` context — it's passed `context.Background()`. So the per-lens ctx used in `sem.acquire(ctx)` is `context.Background()` — **never cancelled**! That means if a lens is queued waiting for a permit and the overall run is killed, the goroutine stays blocked. But since `fanout.Run` is given `context.Background()` too, the lenses aren't cancelled by fanout anyway. The process being killed drops the flocks. So no leak in practice. But wait — there's a real concern: the `acquire` uses the `ctx` passed by fanout, which is `context.Background()`. So `acquire` will **block forever** if no permit frees. If all permits are held by deadlocked goroutines... but goroutines release on completion. Fine. Let me check one more thing — the `MaxConcurrent` semantics in fanout. If `MaxConcurrent` controls how many goroutines run the fn concurrently, then with sem active and size N, the fanout spawns up to N concurrent fn calls. Each fn tries `sem.acquire(ctx)`. Since fanout already limits to N concurrent, and the pool is N, the in-process fanout never blocks on the sem — the sem only throttles ACROSS processes. That's actually the design: "extra goroutines simply block in sem.acquire until a permit frees" — but with MaxConcurrent=N=sem.size, there are no extra goroutines within one process. The blocking happens ACROSS processes. ✓ Correct. Now a subtle bug: with 2 models in a lane, each spawns N goroutines (MaxConcurrent=N), but the pool is N total. So model A's N goroutines grab all N permits; model B's N goroutines block. As A's lenses finish, permits free, B's goroutines acquire. ✓ This is exactly the intended design. Let me verify the `provider_lens_cap` bash function parsing matches the Go `providerOverride`: Bash: splits on `,`, for each item, `k="${item%%=*}"`, `v="${item#*=}"`, trimmed. If `k == p` and `v` non-empty, echo `v`, return. Go: `strings.Cut(item, "=")`, `strings.TrimSpace(k) != provider` skip, `strconv.Atoi(TrimSpace(v))`, `n > 0`. Edge case: `GADFLY_PROVIDER_LENS_CONCURRENCY="ollama-cloud=3,m1pro=1"`. Both parse the same. ✓ Edge case: an item without `=` like `"foo"`: bash `k="foo"`, `v="foo"` (since `${item#*=}` on no-`=` returns the whole). `k != p` for any real provider, skipped. Go: `Cut` returns `ok=false`, skip. ✓ Edge case: trailing comma or empty item. Bash: `k=""`, `v=""`, `v` empty → skip. Go: `Cut("", "=")` returns `("", "", false)`, skip. ✓ Now let me check: is there a scenario where `sem.size` from `GADFLY_LENS_SEM_SIZE` disagrees with `lensConcurrency()`? In the binary, `sem.size` is read via `envInt("GADFLY_LENS_SEM_SIZE", 0)`. `lensConcurrency()` reads `GADFLY_PROVIDER_LENS_CONCURRENCY`/`GADFLY_LENS_CONCURRENCY`. Entrypoint sets `GADFLY_LENS_SEM_SIZE=$budget` where `budget=provider_lens_cap($p)`, which reads the same env vars. So they agree for the model's provider. ✓ I think the change is correct. Let me just double-check one potential issue: when `sem != nil` and `MaxConcurrent = sem.size`, but the number of specialists is LESS than `sem.size`. Then fanout spawns `len(specialists)` goroutines (assuming fanout caps at min(MaxConcurrent, len)). Each acquires a permit. Fine. ✓ Let me verify there's no issue with `acquire` returning a no-op release on ctx-done but the caller already having... no, it returns immediately. One last thing — the `lensSemPollInterval = 150ms`. With 12 goroutines polling a 3-permit pool, each polling every 150ms. Negligible. ✓ I'm confident this is correct. Let me check if there's any issue with the `O_CREAT` race: two processes call `OpenFile("permit.0", O_RDWR|O_CREATE)`. Both succeed (one creates, one opens existing). Both try `Flock(LOCK_EX|LOCK_NB)`. One wins, one gets `EWOULDBLOCK` and closes. ✓ Standard flock semantics. Verdict: No material correctness issues found. Let me do one final sanity check on the status board state when `sem.acquire` fails — the lens is marked `lensFinished` with errored=true. But it was never marked `lensRunning`. So the board goes `queued → finished (errored)`. That's fine — the board logic checks `State != lensFinished` for "still running" (status.go:91). ✓ I'm done. No material correctness issues.</think>## Verdict: No material issues found I reviewed the change through the correctness lens — the flock permit-pool semantics, the in-process fanout sizing, the bash/Go env-map parsing parity, and the cancellation/panic-release orderings. Everything checks out. Verified by reading the actual code (not just the diff): - **`cmd/gadfly/lenssem.go:72` (`tryAcquire`)** — `O_RDWR|O_CREATE` + `FLOCK_EX|LOCK_NB` is the correct non-blocking exclusive-lock pattern. Failure path closes `f` (no fd leak); success returns `func(){ f.Close() }`, and closing the fd drops the flock (lock lives on the open file description). Cross-process exclusion works because flock is per-open-file-description, so two `OpenFile` calls on the same `permit.N` conflict. Confirmed at `lenssem.go:75-83`. - **`cmd/gadfly/main.go:268-272` (fanout sizing)** — when `sem != nil`, `MaxConcurrent = sem.size`. With N permits shared across M model processes, each process spawns ≤N goroutines; total in-flight is bounded by the pool size N, not N×M. A lone model can use the whole budget; extra goroutines block in `sem.acquire`. Correct. - **`cmd/gadfly/main.go:287-297` (acquire/release ordering)** — `defer release()` is registered after a successful acquire and before `reviewWithSpecialist`. The panic-recovery `defer` (registered earlier, so LIFO-runs later) sets `res`/`sw.set(lensFinished)`; then `defer release()` runs and frees the permit. On `ctx`-done from `acquire`, no permit was held, so the early return is leak-free (`acquire` returns a no-op release, `lenssem.go:61`). Verified against `lenssem.go:54-65`. - **`cmd/gadfly/main.go:327-348` vs `entrypoint.sh:257-266`** — `providerOverride` (Go) and `provider_lens_cap` (bash) parse the identical `provider=N,...` syntax. I walked edge cases: missing `=` (bash `v` becomes the whole item but `k≠p` skips; Go `Cut` returns `ok=false` and skips), empty/trailing items (both skip), whitespace (both `TrimSpace`/`tr -d`). They agree, so `GADFLY_LENS_SEM_SIZE` (entrypoint) and `lensConcurrency()` (binary) resolve to the same value for the model's provider — the in-process cap and the cross-process pool are consistent. - **`entrypoint.sh:254-255` + `337-338`** — `LENS_SEM_ROOT` is `rm -rf`'d up front and each lane `mkdir -p`s its subdir; stale permit files from a reused `WORKDIR` can't leak lock state (flock is per-open-FD, and the dir is wiped anyway). Lazy `O_CREAT` in `tryAcquire` means no separate seeding step is needed. Correct. - **`entrypoint.sh:329-344` (`run_lane`)** — every model in the lane is backgrounded with no per-model cap; `wait` joins them. The old `inflight`/`wait -n` model-cap gate is fully removed. Matches the documented invariant. - **`acquire` poll loop (`lenssem.go:54-64`)** — uses `ctx.Done()` + `time.After(lensSemPollInterval)` in a `select`, so cancellation beats the timer; returns `ctx.Err()` with a no-op release. No permit leak on cancellation. The 150 ms poll is negligible against lens passes that run for seconds-to-minutes. No correctness defects found in my lane. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> Let me check whether the comment at line 159 is the only stale reference, and look at lens_concurrency_test more broadly. Verdict: Minor issues - `cmd/gadfly/lens_concurrency_test.go:159` — Stale comment after this PR: `// lane rule entrypoint.sh uses for GADFLY_PROVIDER_CONCURRENCY).` The diff updated the analogous references in `main.go:248-258`, `model.go:165`, and `providerOverride`'s doc (`main.go:336`) to point at `GADFLY_PROVIDER_LENS_CONCURRENCY`, but missed this one in the test file. Verified by grep — `entrypoint.sh` no longer references `GADFLY_PROVIDER_CONCURRENCY` as a lane-key source; `provider_lens_cap` keys off `GADFLY_PROVIDER_LENS_CONCURRENCY`. Low-impact doc rot that future readers will trip on. Suggested fix: rewrite to `…same lane rule entrypoint.sh uses for GADFLY_PROVIDER_LENS_CONCURRENCY).` - `cmd/gadfly/main.go:269-272` — `maxConcurrent := lensConcurrency()` is computed and then unconditionally overwritten when `sem != nil`. Trivial wasted work, but more importantly slightly misleading: the comment block above explains the intent, yet the discarded value reads as if it were a fallback floor rather than a pure no-op when the pool is active. Suggested fix: `maxConcurrent := lensConcurrency(); if sem != nil { maxConcurrent = sem.size }` is fine as-is, but consider gating the call: `maxConcurrent := sem.size; if sem == nil { maxConcurrent = lensConcurrency() }` so the discard is not visually suggestive of a merge. - `cmd/gadfly/lenssem.go:72-86` `tryAcquire` always sweeps `0..size-1`, always preferring `permit.0`. Not a correctness bug (verified — flock contention is symmetric and the test `TestLensSemNeverExceedsSize` confirms the budget holds), but it concentrates contention on the low-index files under heavy load. A `rand.Perm`-style or `start = (start+1) % size` rotating index would spread load. Minor; acceptable for the few-lines-of-stdlib goal the file states. No material issues found in the lenssem implementation, tests, entrypoint wiring, or docs otherwise. The `gadfly-findings` block below reflects only the genuine findings. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> I'll review this through the performance lens. Let me examine the key files. I have enough context. Let me check the in-process fanout sizing once more for the "extra goroutines block" claim — whether fanout.Run with MaxConcurrent larger than needed actually blocks harmlessly. The design: `maxConcurrent = sem.size` (e.g., 3). fanout runs up to 3 lenses concurrently in-process; each one calls `sem.acquire()` which flocks a permit. With 2 models in lane each running 3 lenses, total 6 in-process goroutines contend for 3 flock permits across 2 processes. The in-process `MaxConcurrent=3` is just the per-model goroutine ceiling; cross-process flock does the real throttling. That works. Let me verify the polling concern: with a 150ms poll, when a permit frees, up to 150ms latency before another lens starts. For lens passes that run seconds-to-minutes, negligible. Fine. One concern: `tryAcquire` does `O(size)` syscalls every poll. With budget=3, that's 3 OpenFile+Flock attempts every 150ms per blocked lens — trivial. With a larger budget (say 16) and many blocked lenses, still trivial. Not a hot path. Let me check the `entrypoint.sh` — `run_lane` backgrounds ALL models at once (`review_one "$m" "$sem_dir" "$budget" &`), no per-model cap. With budget=1, all M models start, each lens flocks the single permit — serial across all of them. That matches the design. Good. Performance lens assessment: no N+1, no unbounded growth, no quadratic behavior, no missing pagination. The pool is bounded by `size`. Permit files created lazily (bounded to `size`). Polling is bounded and coarse. No regressions found. **Verdict: No material issues found** - The `lensSem` poll interval (150ms) is coarse but appropriate — lens passes run for seconds-to-minutes, so worst-case permit-acquisition latency is negligible relative to lens duration, and it avoids busy-waiting (verified `lenssem.go:33,54-65`). - `tryAcquire` does `O(size)` `open+flock` syscalls per poll, but `size` is the small per-provider lens budget and polling only happens while *blocked* (not on the hot path of an actively-running lens); for the budgets in play this is trivial (`lenssem.go:72-86`). - `runSpecialists` sizes in-process `MaxConcurrent` to `sem.size`, so a lone model in a lane can consume the whole provider budget; extra goroutines block in `sem.acquire` rather than spinning — no busy-wait, no allocation churn (`main.go:268-272, 290-297`). - `entrypoint.sh run_lane` backgrounds all of a lane's models at once with no per-model cap, but the shared flock pool of `size` permits is the real bound, so model count does not cause unbounded in-flight lens passes (`entrypoint.sh:329-344`). - Permit files are created lazily but bounded to `size` per provider; `rm -rf "$LENS_SEM_ROOT"` at startup prevents unbounded stale-file growth across reused WORKDIRs (`entrypoint.sh:255-256`). - No new allocations or copies introduced on the per-lens hot path beyond a single `os.OpenFile` per permit acquire and a deferred close. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> I have verified all the key claims in the draft against the actual code: - `fanout.Run(context.Background(), ...)` at `main.go:274` — confirmed; the worker `ctx` derives from an uncancellable background context. - `sem.acquire(ctx)` is called at `main.go:291` with that ctx, and `reviewWithSpecialist` (which holds the per-lens timeout at `main.go:356`) is called *after* acquire returns at `main.go:299` — so the timeout cannot rescue a stuck acquire. - `tryAcquire` silently `continue`s on `os.OpenFile` error (`lenssem.go:76-77`) with no logging, conflating "all permits held" with "open failed." - entrypoint `mkdir -p "$sem_dir"` at `entrypoint.sh:338` and `rm -rf "$LENS_SEM_ROOT"` once at startup (`entrypoint.sh:255`) — so in the supported path the dir exists, but a misconfigured `GADFLY_LENS_SEM_DIR` in a local run would hang silently. The single finding survives verification. The draft's other observations (panic defer ordering, garbage-budget fallback, SIGKILL flock release, empty-concurrency fallback) were all confirmed correct but were explicitly noted as *not* findings, so they don't appear below. ## VERDICT: Minor issues - **`cmd/gadfly/lenssem.go:54-65` / `cmd/gadfly/lenssem.go:75-78` — `acquire` can hang forever on a broken pool dir, silently.** `runSpecialists` calls `fanout.Run(context.Background(), …)` (`main.go:274`), so the worker `ctx` passed to `sem.acquire(ctx)` (`main.go:291`) is never cancelled by fanout — the `ctx.Done()` branch in `acquire` is unreachable in production. If `tryAcquire`'s `os.OpenFile` fails for every permit file (dir deleted mid-run, permission revoked, ENOSPC), `acquire` polls every 150ms indefinitely with no error escalation and no log. The per-lens timeout in `reviewWithSpecialist` (`main.go:356`) is applied *after* `acquire` returns (`main.go:299`), so it cannot rescue a stuck acquire — the lens (and the model process) hangs without producing the "did not run" advisory result the cancellation comment at `main.go:289` promises. `tryAcquire` also conflates "all permits held" with "open failed" — both return `(nil, false)` and the `continue` at `lenssem.go:76-77` swallows the `os.OpenFile` error, so a sibling model process can't distinguish "wait" from "give up." In the supported entrypoint path this is unlikely (the lane's `sem_dir` is `mkdir -p`'d at `entrypoint.sh:338` and nothing deletes it during the run), but a misconfigured `GADFLY_LENS_SEM_DIR` in a local/manual run degrades to a silent hang rather than the in-process fallback. Suggest: log on the first `OpenFile` failure and, if every file fails on a sweep, return a sentinel error (so the lens surfaces as "did not run" and the binary can fall back to in-process throttling) or bound the retries. (Verified against `lenssem.go`, `main.go:274,291,299,356`, `entrypoint.sh:255,338`.) </details> </details> <details><summary><b>opencode/kimi-k2.6:cloud</b> (opencode) — Blocking issues found</summary> **Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **No material issues found** No security-relevant issues were identified in this PR. The changes are focused on concurrency plumbing (replacing a model-level cap with a provider-wide lens budget) and do not introduce new attack surface: - **`lenssem.go`** uses `flock` on permit files with `0o600` creation mode; no credential handling, no deserialization, no network I/O. - **`activeLensSem`** reads `GADFLY_LENS_SEM_DIR`/`GADFLY_LENS_SEM_SIZE` from env, but these are set exclusively by `entrypoint.sh` to controlled paths under `$WORKDIR`; no user input reaches them. - **`entrypoint.sh`** changes are purely about concurrency configuration; the deprecated `provider_concurrency` input is correctly ignored without creating authz side effects. - **Docs / workflow** changes are documentation-only and contain no secret exposure or misconfiguration. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> No material issues found. I scrutinized the provider-wide lens budget implementation through the correctness lens. The cross-process permit pool (`lenssem.go`) correctly uses `flock` on lazily-created permit files: locks are per-open-file-description and auto-release on process death, which matches the design intent. The polling loop in `acquire` handles context cancellation without leaking permits. The integration in `main.go` sizes the in-process fanout to `sem.size` when the pool is active, so a lone model can consume the full provider budget while extra goroutines block in `sem.acquire()` — this is the intended behavior. Fallback to in-process `lensConcurrency()` when the env vars are unset preserves standalone/test behavior. The shell-side `provider_lens_cap` sanitizes the budget to `>= 1` before passing it to the binary, and `entrypoint.sh` correctly seeds one shared directory per provider lane. `activeLensSem` gracefully degrades to `nil` on missing/invalid env values. I re-derived the `lensSemPollInterval` (150 ms) against the stated lens-pass duration of "seconds to minutes": the average delay on a freed permit is ~75 ms, which is <0.2 % of a 60-second lens and <8 % of a 1-second lens — negligible overhead for avoiding IPC primitives. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> **Minor issues** - **`cmd/gadfly/main.go:100-103`** — The comment on `defaultLensConcurrency` is stale: it says the limit applies "within a single model," but after this change `GADFLY_LENS_CONCURRENCY` is the **provider-wide** lens budget shared across all models in the lane. The constant is only used as a per-model fallback in standalone runs; the comment should match the new architecture or note the dual behavior so future readers don't assume the old per-model semantics. - **`entrypoint.sh:357`** — Stale comment: "even models still waiting on their provider lane's concurrency cap" refers to the removed model-cap behavior. Models are no longer throttled by a concurrency cap; they all run at once and only lenses wait on the permit pool. Update to something like "even lenses still waiting on their provider lane's shared permit pool" to avoid misleading anyone tracing the flow. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **Verdict:** No material issues found I reviewed the concurrency change through the performance lens and verified the new cross-process permit pool against the checked-out code. The `lenssem.go` flock-based pool correctly bounds total lens passes in flight per provider: `tryAcquire` sweeps `s.size` permit files with non-blocking `syscall.Flock`, and `acquire` polls at 150 ms intervals. I traced `runSpecialists` in `main.go` and confirmed `maxConcurrent` is capped at `sem.size` when the shared pool is active, so in-flight goroutines can never exceed the budget. The main performance tradeoff is intentional and correctly implemented: - **Old:** `cap` model processes × `lensConcurrency` lenses = multiplicative concurrency, with tail-stall when a model held its slot through its last lens. - **New:** All model processes in a lane start at once, but only `budget` lenses run concurrently across the entire lane. This eliminates the tail stall. I checked for unbounded goroutine growth, memory leaks, N+1 query patterns, and hot-path regressions. The polling loop in `acquire` creates minor timer/syscall overhead, but the PR explicitly acknowledges this ("a coarse poll adds negligible latency") and it is dwarfed by LLM inference time. The flock files are auto-released on process death, preventing permit leaks. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Blocking issues found</summary> **Blocking issues found** - **`cmd/gadfly/lenssem.go:76-78`** — `tryAcquire` silently swallows `os.OpenFile` errors (`continue` on any `err != nil`). If the permit directory is missing, unwritable, or is a file instead of a directory, **every** permit file fails to open. `tryAcquire` then returns `false`, and `acquire` enters an infinite `select` loop sleeping 150 ms and retrying forever. The `ctx` passed from `fanout.Run` is derived from `context.Background()` and never cancels, so the goroutine never exits. It consumes a `fanout` slot and stalls every remaining lens for that model. **Fix:** return a sentinel error from `tryAcquire` when *no* file could be opened, and abort `acquire` (or at least surface the error) instead of polling indefinitely. - **`cmd/gadfly/lenssem.go:79-82`** — `tryAcquire` treats **any** `syscall.Flock` error as “already locked” (`f.Close(); continue`). On filesystems that do not support `flock` (e.g. some NFS mounts, FUSE, or certain network shares), `Flock` returns `EOPNOTSUPP` or `EINVAL`. Because every file fails with the *same* hard error, `tryAcquire` again returns `false` forever, and `acquire` spins indefinitely with the same consequence as above. **Fix:** distinguish `EAGAIN`/`EWOULDBLOCK` (try the next file) from other errors; on a non-retryable error, abort and surface it. </details> </details> </details> <sub>Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.</sub>
steve added 1 commit 2026-07-18 16:47:37 +00:00
fix(concurrency): address gadfly review — fail-open pool + doc drift
Build & push image / build-and-push (pull_request) Successful in 4s
6a74b64c7a
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]>
steve merged commit b37cd09dc9 into main 2026-07-18 16:48:26 +00:00
steve deleted branch feat/provider-wide-lens-budget 2026-07-18 16:48:26 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/gadfly#27