c3d09d3bd4
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>
132 lines
4.1 KiB
Go
132 lines
4.1 KiB
Go
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)
|
|
}
|
|
}
|