feat: live status-board comment + full-fleet dogfood (#1)
Build & push image / build-and-push (push) Successful in 6s
Build & push image / build-and-push (push) Successful in 6s
Phase 3: one consolidated, live-updating PR comment aggregating every model's per-lens progress (queued -> running -> finished + verdict), so the swarm's progress is visible at a glance and a watcher can tell when it's done. Opt-in statusWriter in the binary (atomic writes) + a background status-board.sh renderer wired through entrypoint.sh; default on, GADFLY_STATUS_BOARD=0 to disable. Also restores gadfly's dogfood swarm to the full cloud fleet (9 cloud + M5; M1 dropped as too slow) matching mort, and folds in the 3 real bugs the swarm found on its own PR (skip-binary stuck-waiting, panic-stuck lens, busy-loop on bad poll interval). All 36 findings graded via the gadfly MCP (18 real / 18 false-positive). gofmt clean, go vet quiet, go build + go test -race green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com> Co-committed-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
This commit was merged in pull request #1.
This commit is contained in:
+19
-1
@@ -218,6 +218,11 @@ func run() error {
|
||||
func runSpecialists(mdl llm.Model, fsTools *repoFS, base string, specialists []Specialist, task, diff string) []specialistResult {
|
||||
results := make([]specialistResult, len(specialists))
|
||||
|
||||
// Optional live status board: publishes this model's per-lens progress to a
|
||||
// file the entrypoint board renders. Inert (no-op) unless GADFLY_STATUS_FILE
|
||||
// is set, so plain runs are unaffected.
|
||||
sw := newStatusWriter(os.Getenv("GADFLY_MODEL"), modelProvider(), specialists)
|
||||
|
||||
conc := min(lensConcurrency(), len(specialists))
|
||||
|
||||
sem := make(chan struct{}, conc)
|
||||
@@ -228,8 +233,21 @@ func runSpecialists(mdl llm.Model, fsTools *repoFS, base string, specialists []S
|
||||
go func(i int, sp Specialist) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
// A panic in one lens must not crash the whole binary (which would
|
||||
// kill every other lens's output) or leave this lens stuck at
|
||||
// "running" on the status board. Recover, record it as an errored
|
||||
// result, and mark the lens finished so the board can complete.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
results[i] = specialistResult{spec: sp, out: fmt.Sprintf("⚠️ This reviewer panicked: %v", r), verdict: verdictUnknown, errored: true}
|
||||
sw.set(sp.Name, lensFinished, "", true)
|
||||
}
|
||||
}()
|
||||
sw.set(sp.Name, lensRunning, "", false)
|
||||
out, errored := reviewWithSpecialist(mdl, fsTools, base, sp, task, diff)
|
||||
results[i] = specialistResult{spec: sp, out: out, verdict: parseVerdict(out), errored: errored}
|
||||
v := parseVerdict(out)
|
||||
results[i] = specialistResult{spec: sp, out: out, verdict: v, errored: errored}
|
||||
sw.set(sp.Name, lensFinished, v.label(), errored)
|
||||
}(i, sp)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Lens states for the live status board. A lens starts queued, becomes running
|
||||
// when its pass begins, and ends finished (with a verdict, or errored).
|
||||
const (
|
||||
lensQueued = "queued"
|
||||
lensRunning = "running"
|
||||
lensFinished = "finished"
|
||||
)
|
||||
|
||||
// lensStatus is one specialist lens's progress, as rendered by the entrypoint
|
||||
// status board (scripts/status-board.sh).
|
||||
type lensStatus struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"` // queued | running | finished
|
||||
Verdict string `json:"verdict,omitempty"` // set when finished (the lens's label)
|
||||
Errored bool `json:"errored,omitempty"` // the lens failed to complete
|
||||
}
|
||||
|
||||
// modelStatus is the on-disk shape one model process publishes for the live
|
||||
// status board: a snapshot of this model's lenses as they progress. The board
|
||||
// reads every model's file and renders a single consolidated PR comment.
|
||||
type modelStatus struct {
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
Started int64 `json:"started"` // unix seconds
|
||||
Updated int64 `json:"updated"` // unix seconds, bumped on every change
|
||||
Done bool `json:"done"` // all lenses finished
|
||||
Lenses []lensStatus `json:"lenses"`
|
||||
}
|
||||
|
||||
// statusWriter maintains a model's status file as its lenses progress. It is
|
||||
// purely opt-in: when GADFLY_STATUS_FILE is unset the writer's path is empty and
|
||||
// every method is a no-op, so a plain run (and the unit tests) never touch the
|
||||
// filesystem and behave exactly as before. Writes are atomic (temp file +
|
||||
// rename within the same dir) so the board never reads a half-written file even
|
||||
// though lenses can finish concurrently.
|
||||
type statusWriter struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
st modelStatus
|
||||
}
|
||||
|
||||
// newStatusWriter seeds a writer with every lens queued and flushes the initial
|
||||
// snapshot. model/provider are echoed into the file so the board can render
|
||||
// them without re-deriving from the filename (which is sanitized). The status
|
||||
// file path comes from GADFLY_STATUS_FILE (set by run.sh per model); when empty
|
||||
// the writer is inert.
|
||||
func newStatusWriter(model, provider string, specialists []Specialist) *statusWriter {
|
||||
w := &statusWriter{path: strings.TrimSpace(os.Getenv("GADFLY_STATUS_FILE"))}
|
||||
w.st = modelStatus{
|
||||
Model: model,
|
||||
Provider: provider,
|
||||
Started: time.Now().Unix(),
|
||||
}
|
||||
for _, sp := range specialists {
|
||||
w.st.Lenses = append(w.st.Lenses, lensStatus{Name: sp.Name, State: lensQueued})
|
||||
}
|
||||
w.flush()
|
||||
return w
|
||||
}
|
||||
|
||||
// set transitions a lens to a new state (and verdict/errored when finished),
|
||||
// recomputes the overall done flag, and atomically rewrites the file. Unknown
|
||||
// lens names are ignored. Safe for concurrent callers (one goroutine per lens).
|
||||
func (w *statusWriter) set(name, state, verdict string, errored bool) {
|
||||
if w == nil || w.path == "" {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for i := range w.st.Lenses {
|
||||
if w.st.Lenses[i].Name == name {
|
||||
w.st.Lenses[i].State = state
|
||||
w.st.Lenses[i].Verdict = verdict
|
||||
w.st.Lenses[i].Errored = errored
|
||||
break
|
||||
}
|
||||
}
|
||||
done := true
|
||||
for _, l := range w.st.Lenses {
|
||||
if l.State != lensFinished {
|
||||
done = false
|
||||
break
|
||||
}
|
||||
}
|
||||
w.st.Done = done
|
||||
w.flush()
|
||||
}
|
||||
|
||||
// flush writes the current snapshot atomically. Best-effort: any error is
|
||||
// swallowed (the status board is advisory and must never affect the review).
|
||||
func (w *statusWriter) flush() {
|
||||
if w.path == "" {
|
||||
return
|
||||
}
|
||||
w.st.Updated = time.Now().Unix()
|
||||
data, err := json.MarshalIndent(&w.st, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(w.path)
|
||||
tmp, err := os.CreateTemp(dir, ".status-*.tmp")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return
|
||||
}
|
||||
// Rename is atomic within the same filesystem, so the board reader sees
|
||||
// either the old complete file or the new complete file — never a partial.
|
||||
if err := os.Rename(tmpName, w.path); err != nil {
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// readStatus loads a modelStatus written by the statusWriter.
|
||||
func readStatus(t *testing.T, path string) modelStatus {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read status file: %v", err)
|
||||
}
|
||||
var st modelStatus
|
||||
if err := json.Unmarshal(data, &st); err != nil {
|
||||
t.Fatalf("unmarshal status: %v", err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func TestStatusWriterLifecycle(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "glm.json")
|
||||
t.Setenv("GADFLY_STATUS_FILE", path)
|
||||
|
||||
specs := []Specialist{
|
||||
{Name: "security", Title: "Security"},
|
||||
{Name: "correctness", Title: "Correctness"},
|
||||
}
|
||||
w := newStatusWriter("glm-5.2:cloud", "ollama-cloud", specs)
|
||||
|
||||
// Initial snapshot: both lenses queued, model not done, metadata echoed.
|
||||
st := readStatus(t, path)
|
||||
if st.Model != "glm-5.2:cloud" || st.Provider != "ollama-cloud" {
|
||||
t.Fatalf("model/provider not echoed: %+v", st)
|
||||
}
|
||||
if len(st.Lenses) != 2 {
|
||||
t.Fatalf("want 2 lenses, got %d", len(st.Lenses))
|
||||
}
|
||||
for _, l := range st.Lenses {
|
||||
if l.State != lensQueued {
|
||||
t.Fatalf("lens %q want queued, got %q", l.Name, l.State)
|
||||
}
|
||||
}
|
||||
if st.Done {
|
||||
t.Fatal("model marked done while lenses still queued")
|
||||
}
|
||||
if st.Started == 0 {
|
||||
t.Fatal("started timestamp not set")
|
||||
}
|
||||
|
||||
// Transition one lens through running -> finished; model not yet done.
|
||||
w.set("security", lensRunning, "", false)
|
||||
if got := readStatus(t, path); got.Lenses[0].State != lensRunning {
|
||||
t.Fatalf("security want running, got %q", got.Lenses[0].State)
|
||||
}
|
||||
w.set("security", lensFinished, "No material issues found", false)
|
||||
st = readStatus(t, path)
|
||||
if st.Lenses[0].State != lensFinished || st.Lenses[0].Verdict != "No material issues found" {
|
||||
t.Fatalf("security finish not recorded: %+v", st.Lenses[0])
|
||||
}
|
||||
if st.Done {
|
||||
t.Fatal("model marked done with one lens still queued")
|
||||
}
|
||||
|
||||
// Finish the second lens (errored) -> model done.
|
||||
w.set("correctness", lensFinished, "Reviewed", true)
|
||||
st = readStatus(t, path)
|
||||
if !st.Done {
|
||||
t.Fatal("model should be done after all lenses finished")
|
||||
}
|
||||
if !st.Lenses[1].Errored {
|
||||
t.Fatal("errored flag not recorded for correctness")
|
||||
}
|
||||
if st.Updated < st.Started {
|
||||
t.Fatalf("updated (%d) should be >= started (%d)", st.Updated, st.Started)
|
||||
}
|
||||
}
|
||||
|
||||
// With GADFLY_STATUS_FILE unset the writer is inert: no file, no panic.
|
||||
func TestStatusWriterDisabled(t *testing.T) {
|
||||
t.Setenv("GADFLY_STATUS_FILE", "")
|
||||
w := newStatusWriter("m", "p", []Specialist{{Name: "security"}})
|
||||
w.set("security", lensFinished, "Minor issues", false)
|
||||
// Nothing to assert beyond "did not panic / did not write" — a nil-safe set
|
||||
// on the disabled writer is the contract.
|
||||
if w.path != "" {
|
||||
t.Fatalf("expected empty path when disabled, got %q", w.path)
|
||||
}
|
||||
}
|
||||
|
||||
// set must ignore unknown lens names rather than panic or append.
|
||||
func TestStatusWriterUnknownLens(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "s.json")
|
||||
t.Setenv("GADFLY_STATUS_FILE", path)
|
||||
w := newStatusWriter("m", "p", []Specialist{{Name: "security"}})
|
||||
w.set("does-not-exist", lensRunning, "", false)
|
||||
if st := readStatus(t, path); len(st.Lenses) != 1 || st.Lenses[0].State != lensQueued {
|
||||
t.Fatalf("unknown lens mutated state: %+v", st.Lenses)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user