`harness loop` polls the /issues.json intake on an interval (default
120s, --once for cron-style single scans), runs one bounded turn per
new/updated issue — deduped by issue id + updated_on in an append-only
loop.jsonl — then writes the REPORT back as a Redmine journal note,
transitions status per [redmine.status_map] (In Progress -> Done by
name, resolved via /issue_statuses.json), and refreshes the dedup marker
to the post-writeback updated_on so its own notes never re-trigger it.
Turns are sequential (v0); failed turns are recorded, not retried, so a
down proxy cannot hot-loop the poll. The optional Gitea REPORT commit
([gitea] commit_reports, off) rides the same flow. No slot files, no
doorbell screens, no queue scripts — the bash middle layer is replaced,
not wrapped.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
416 lines
12 KiB
Go
416 lines
12 KiB
Go
package loop
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"ukrrs.com/mopac/harness/internal/config"
|
|
)
|
|
|
|
// fakeRedmine is a mutable in-memory Redmine: /issues.json scope listing,
|
|
// PUT /issues/{id}.json (note + status), /issue_statuses.json, and
|
|
// GET /issues/{id}.json (updated_on). Notes bump updated_on like the real
|
|
// SoR, so the loop's refresh logic is exercised for real.
|
|
type fakeRedmine struct {
|
|
mu sync.Mutex
|
|
issues map[string]struct {
|
|
subject string
|
|
status string
|
|
updatedOn string
|
|
notes []string
|
|
}
|
|
statusIDs map[string]int
|
|
noted []string // issue ids that got a note, in order
|
|
srv *httptest.Server
|
|
}
|
|
|
|
func newFakeRedmine(t *testing.T) *fakeRedmine {
|
|
t.Helper()
|
|
f := &fakeRedmine{
|
|
issues: map[string]struct {
|
|
subject string
|
|
status string
|
|
updatedOn string
|
|
notes []string
|
|
}{},
|
|
statusIDs: map[string]int{"New": 1, "In Progress": 2, "Done": 3},
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /issues.json", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var b strings.Builder
|
|
b.WriteString(`{"issues":[`)
|
|
first := true
|
|
for id, is := range f.issues {
|
|
if !first {
|
|
b.WriteString(",")
|
|
}
|
|
first = false
|
|
fmt.Fprintf(&b, `{"id":%s,"subject":%q,"description":"do the thing","updated_on":%q,"status":{"name":%q},"custom_fields":[{"name":"Class","value":"primary"}]}`,
|
|
id, is.subject, is.updatedOn, is.status)
|
|
}
|
|
b.WriteString(`]}`)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(b.String()))
|
|
})
|
|
mux.HandleFunc("PUT /issues/", func(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/issues/"), ".json")
|
|
var body struct {
|
|
Issue struct {
|
|
Notes string `json:"notes"`
|
|
StatusID int `json:"status_id"`
|
|
} `json:"issue"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
is, ok := f.issues[id]
|
|
if !ok {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if body.Issue.Notes != "" {
|
|
is.notes = append(is.notes, body.Issue.Notes)
|
|
f.noted = append(f.noted, id)
|
|
}
|
|
if body.Issue.StatusID != 0 {
|
|
for name, sid := range f.statusIDs {
|
|
if sid == body.Issue.StatusID {
|
|
is.status = name
|
|
}
|
|
}
|
|
}
|
|
is.updatedOn = time.Now().UTC().Format(time.RFC3339)
|
|
f.issues[id] = is
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
mux.HandleFunc("GET /issue_statuses.json", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var b strings.Builder
|
|
b.WriteString(`{"issue_statuses":[`)
|
|
first := true
|
|
for name, id := range f.statusIDs {
|
|
if !first {
|
|
b.WriteString(",")
|
|
}
|
|
first = false
|
|
fmt.Fprintf(&b, `{"id":%d,"name":%q}`, id, name)
|
|
}
|
|
b.WriteString(`]}`)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(b.String()))
|
|
})
|
|
mux.HandleFunc("GET /issues/", func(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/issues/"), ".json")
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
is, ok := f.issues[id]
|
|
if !ok {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
fmt.Fprintf(w, `{"issue":{"id":%s,"updated_on":%q}}`, id, is.updatedOn)
|
|
})
|
|
f.srv = httptest.NewServer(mux)
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeRedmine) addIssue(id, subject, status, updatedOn string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.issues[id] = struct {
|
|
subject string
|
|
status string
|
|
updatedOn string
|
|
notes []string
|
|
}{subject: subject, status: status, updatedOn: updatedOn}
|
|
}
|
|
|
|
func (f *fakeRedmine) setUpdatedOn(id, updatedOn string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if is, ok := f.issues[id]; ok {
|
|
is.updatedOn = updatedOn
|
|
f.issues[id] = is
|
|
}
|
|
}
|
|
|
|
func (f *fakeRedmine) status(id string) string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.issues[id].status
|
|
}
|
|
|
|
func (f *fakeRedmine) noteCount(id string) int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.issues[id].notes)
|
|
}
|
|
|
|
func loopTestConfig(t *testing.T, redmineURL, llmURL string) *config.Config {
|
|
t.Helper()
|
|
cfg := testConfig(t, llmURL)
|
|
cfg.Redmine.URL = redmineURL
|
|
cfg.Redmine.KeyRef = "literal:rm-key"
|
|
cfg.Redmine.ScopeQuery = "project=mopac"
|
|
cfg.Redmine.StatusMap = map[string]string{"In Progress": "Done"}
|
|
cfg.Loop.StateDir = filepath.Join(t.TempDir(), "state-loop")
|
|
cfg.Loop.PollIntervalSecs = 3600 // between-scan sleep; tests use --once scans
|
|
return cfg
|
|
}
|
|
|
|
// runOneScan runs the daemon in --once mode against the given servers.
|
|
func runOneScan(t *testing.T, cfg *config.Config, dryRun bool) string {
|
|
t.Helper()
|
|
var out strings.Builder
|
|
cond, err := New(cfg, &out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cond.RunLoop(context.Background(), LoopOpts{Once: true, DryRun: dryRun}); err != nil {
|
|
t.Fatalf("RunLoop: %v (output:\n%s)", err, out.String())
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func TestLoopDispatchNoteStatusDedup(t *testing.T) {
|
|
rm := newFakeRedmine(t)
|
|
rm.addIssue("421", "Self-host: write the note", "In Progress", "2026-08-28T21:30:00Z")
|
|
f := newFakeLLM(t, textMsg("Report body: the turn ran."))
|
|
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
|
|
|
out := runOneScan(t, cfg, false)
|
|
|
|
// One LLM turn, routed through the class map.
|
|
if f.requestCount() != 1 {
|
|
t.Fatalf("LLM calls = %d, want 1", f.requestCount())
|
|
}
|
|
for _, want := range []string{
|
|
"dispatch 421",
|
|
"noted REPORT on #421",
|
|
"status In Progress -> Done on #421",
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("output missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
if rm.status("421") != "Done" {
|
|
t.Errorf("issue status = %q, want Done", rm.status("421"))
|
|
}
|
|
if rm.noteCount("421") != 1 {
|
|
t.Fatalf("notes on #421 = %d, want 1", rm.noteCount("421"))
|
|
}
|
|
if body := rm.issues["421"].notes[0]; !strings.Contains(body, "Report body: the turn ran.") ||
|
|
!strings.Contains(body, "# REPORT - teststack - 421") {
|
|
t.Errorf("note body wrong:\n%s", body)
|
|
}
|
|
|
|
// REPORT file landed too.
|
|
reports, err := filepath.Glob(filepath.Join(cfg.ReportDir, "REPORT-teststack-421-*.md"))
|
|
if err != nil || len(reports) != 1 {
|
|
t.Fatalf("REPORT files = %v err=%v", reports, err)
|
|
}
|
|
|
|
// Second scan: our own note bumped updated_on, but the refresh must
|
|
// have advanced the dedup marker -> no re-dispatch.
|
|
f2 := newFakeLLM(t, textMsg("must not run"))
|
|
cfg.LiteLLM.BaseURL = f2.srv.URL
|
|
out2 := runOneScan(t, cfg, false)
|
|
if f2.requestCount() != 0 {
|
|
t.Fatalf("second scan re-dispatched (LLM calls = %d)", f2.requestCount())
|
|
}
|
|
if !strings.Contains(out2, "0 new/updated") {
|
|
t.Errorf("second scan should find nothing new:\n%s", out2)
|
|
}
|
|
if rm.noteCount("421") != 1 {
|
|
t.Errorf("second scan added notes: %d", rm.noteCount("421"))
|
|
}
|
|
}
|
|
|
|
func TestLoopRedispatchOnIssueUpdate(t *testing.T) {
|
|
rm := newFakeRedmine(t)
|
|
rm.addIssue("421", "First edit", "In Progress", "2026-08-28T21:30:00Z")
|
|
f := newFakeLLM(t, textMsg("first reply"))
|
|
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
|
|
|
runOneScan(t, cfg, false)
|
|
if f.requestCount() != 1 {
|
|
t.Fatalf("first scan LLM calls = %d", f.requestCount())
|
|
}
|
|
|
|
// PMO updates the issue (bumps updated_on): the loop re-releases it.
|
|
rm.setUpdatedOn("421", "2026-08-28T22:30:00Z")
|
|
out := runOneScan(t, cfg, false)
|
|
if !strings.Contains(out, "1 new/updated") {
|
|
t.Errorf("updated issue should be new:\n%s", out)
|
|
}
|
|
if f.requestCount() != 2 {
|
|
t.Errorf("LLM calls after update = %d, want 2", f.requestCount())
|
|
}
|
|
if rm.noteCount("421") != 2 {
|
|
t.Errorf("notes after update = %d, want 2", rm.noteCount("421"))
|
|
}
|
|
}
|
|
|
|
func TestLoopTurnFailureRecordedNoHotLoop(t *testing.T) {
|
|
rm := newFakeRedmine(t)
|
|
rm.addIssue("421", "Broken task", "In Progress", "2026-08-28T21:30:00Z")
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "proxy down", http.StatusBadGateway)
|
|
}))
|
|
defer srv.Close()
|
|
cfg := loopTestConfig(t, rm.srv.URL, srv.URL)
|
|
|
|
out := runOneScan(t, cfg, false)
|
|
if !strings.Contains(out, "turn failed") {
|
|
t.Errorf("output should record the failed turn:\n%s", out)
|
|
}
|
|
// No note, no status change on a failed turn...
|
|
if rm.noteCount("421") != 0 || rm.status("421") != "In Progress" {
|
|
t.Errorf("failed turn must not note/status: notes=%d status=%s", rm.noteCount("421"), rm.status("421"))
|
|
}
|
|
// ...and the next scan must NOT retry it (updated_on marker consumed).
|
|
out2 := runOneScan(t, cfg, false)
|
|
if !strings.Contains(out2, "0 new/updated") {
|
|
t.Errorf("failed turn hot-looped:\n%s", out2)
|
|
}
|
|
}
|
|
|
|
func TestLoopDryRunDispatchesNothing(t *testing.T) {
|
|
rm := newFakeRedmine(t)
|
|
rm.addIssue("421", "Would-be task", "In Progress", "2026-08-28T21:30:00Z")
|
|
f := newFakeLLM(t, textMsg("unused"))
|
|
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
|
|
|
out := runOneScan(t, cfg, true)
|
|
if f.requestCount() != 0 {
|
|
t.Errorf("dry-run made %d LLM calls", f.requestCount())
|
|
}
|
|
if !strings.Contains(out, "would dispatch 421") {
|
|
t.Errorf("dry-run should print the would-be dispatch:\n%s", out)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(cfg.Loop.StateDir, "loop.jsonl")); !os.IsNotExist(err) {
|
|
t.Errorf("dry-run must not write loop state")
|
|
}
|
|
// A dry-run must not consume the task: a real scan still dispatches.
|
|
runOneScan(t, cfg, false)
|
|
if f.requestCount() != 1 {
|
|
t.Errorf("LLM calls after real scan = %d, want 1", f.requestCount())
|
|
}
|
|
}
|
|
|
|
func TestLoopStateSurvivesRestart(t *testing.T) {
|
|
rm := newFakeRedmine(t)
|
|
rm.addIssue("421", "Persisted", "In Progress", "2026-08-28T21:30:00Z")
|
|
f := newFakeLLM(t, textMsg("reply"))
|
|
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
|
|
|
runOneScan(t, cfg, false)
|
|
|
|
// New conductor = process restart: state reloads from loop.jsonl, and
|
|
// the refresh line (post-note updated_on) is what dedups.
|
|
f2 := newFakeLLM(t, textMsg("must not run"))
|
|
cfg.LiteLLM.BaseURL = f2.srv.URL
|
|
runOneScan(t, cfg, false)
|
|
if f2.requestCount() != 0 {
|
|
t.Errorf("restart re-dispatched a processed issue")
|
|
}
|
|
}
|
|
|
|
func TestLoopStatusMapMissLeavesStatus(t *testing.T) {
|
|
rm := newFakeRedmine(t)
|
|
rm.addIssue("421", "Unmapped status", "Review", "2026-08-28T21:30:00Z")
|
|
f := newFakeLLM(t, textMsg("reply"))
|
|
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
|
|
|
out := runOneScan(t, cfg, false)
|
|
if strings.Contains(out, "status Review ->") {
|
|
t.Errorf("unmapped status must not transition:\n%s", out)
|
|
}
|
|
if rm.status("421") != "Review" {
|
|
t.Errorf("status changed: %q", rm.status("421"))
|
|
}
|
|
if rm.noteCount("421") != 1 {
|
|
t.Errorf("note should still land, got %d", rm.noteCount("421"))
|
|
}
|
|
}
|
|
|
|
func TestLoopRequiresRedmineConfig(t *testing.T) {
|
|
cfg := testConfig(t, "http://unused")
|
|
cond, err := New(cfg, os.Stdout)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = cond.RunLoop(context.Background(), LoopOpts{Once: true})
|
|
if err == nil || !strings.Contains(err.Error(), "[redmine] url is required") {
|
|
t.Fatalf("err = %v, want missing-redmine error", err)
|
|
}
|
|
}
|
|
|
|
func TestLoopGiteaCommitStep(t *testing.T) {
|
|
rm := newFakeRedmine(t)
|
|
rm.addIssue("421", "Commit me", "In Progress", "2026-08-28T21:30:00Z")
|
|
f := newFakeLLM(t, textMsg("reply with body"))
|
|
|
|
var mu sync.Mutex
|
|
var commits []map[string]any
|
|
var authHeaders []string
|
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
|
|
if r.Method == http.MethodGet {
|
|
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
var body map[string]any
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
commits = append(commits, body)
|
|
w.Write([]byte(`{"content":{"path":"x"}}`))
|
|
}))
|
|
defer gitea.Close()
|
|
|
|
cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL)
|
|
cfg.Gitea = config.GiteaConfig{
|
|
URL: gitea.URL, KeyRef: "literal:gt-key",
|
|
Owner: "ukrrs", Repo: "reports", Branch: "main",
|
|
CommitReports: true,
|
|
}
|
|
out := runOneScan(t, cfg, false)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(commits) != 1 {
|
|
t.Fatalf("gitea commits = %d, want 1", len(commits))
|
|
}
|
|
if authHeaders[0] != "token gt-key" {
|
|
t.Errorf("gitea auth = %q", authHeaders[0])
|
|
}
|
|
path, _ := commits[0]["message"].(string)
|
|
if !strings.Contains(out, "gitea: committed") {
|
|
t.Errorf("output missing gitea commit line:\n%s", out)
|
|
}
|
|
if msg, _ := commits[0]["message"].(string); !strings.Contains(msg, "task 421") {
|
|
t.Errorf("commit message = %q", msg)
|
|
}
|
|
_ = path
|
|
if commits[0]["branch"] != "main" {
|
|
t.Errorf("commit branch = %v", commits[0]["branch"])
|
|
}
|
|
}
|