harness loop: self-hosting daemon — Redmine SoR drives its own turns

`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
This commit is contained in:
2026-08-28 22:17:03 -05:00
parent c869ac1b06
commit ecc0ee874b
10 changed files with 1359 additions and 22 deletions
+11 -5
View File
@@ -70,6 +70,10 @@ func (c *RedmineClient) ListTasks(ctx context.Context) ([]task.Task, error) {
ID int `json:"id"`
Subject string `json:"subject"`
Description string `json:"description"`
UpdatedOn string `json:"updated_on"`
Status struct {
Name string `json:"name"`
} `json:"status"`
CustomFields []struct {
Name string `json:"name"`
Value string `json:"value"`
@@ -93,11 +97,13 @@ func (c *RedmineClient) ListTasks(ctx context.Context) ([]task.Task, error) {
prompt = is.Subject
}
tasks = append(tasks, task.Task{
ID: strconv.Itoa(is.ID),
Subject: is.Subject,
Prompt: prompt,
Class: class,
Source: "redmine",
ID: strconv.Itoa(is.ID),
Subject: is.Subject,
Prompt: prompt,
Class: class,
Source: "redmine",
UpdatedOn: is.UpdatedOn,
Status: is.Status.Name,
})
}
return tasks, nil
+250
View File
@@ -0,0 +1,250 @@
// Self-host daemon: `harness loop` polls the Redmine intake on an interval,
// runs ONE bounded turn per new/updated issue (sequentially, v0), notes the
// REPORT back on the issue, transitions status per the config map, and
// optionally commits the REPORT to gitea. Redmine is the SoR; this loop is
// the worker. It replaces the crossfeed bash stack: no slot files, no
// doorbell screens, no queue scripts.
package loop
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"ukrrs.com/mopac/harness/internal/models"
"ukrrs.com/mopac/harness/internal/task"
"ukrrs.com/mopac/harness/internal/writeback"
)
// LoopOpts controls the daemon.
type LoopOpts struct {
Interval time.Duration // poll interval; 0 = [loop] poll_interval_secs
Once bool // single scan then return (cron-able)
DryRun bool // scan + print what would dispatch; no turns, no state writes
}
// RunLoop is the daemon body. It returns nil on context cancel (SIGINT),
// and a non-nil error only for startup/config failures.
func (c *Conductor) RunLoop(ctx context.Context, opts LoopOpts) error {
rc := c.cfg.Redmine
if rc.URL == "" {
return fmt.Errorf("loop: [redmine] url is required (the loop is Redmine-driven; use `harness once --demo` for the demo issue)")
}
interval := opts.Interval
if interval <= 0 {
interval = time.Duration(c.cfg.Loop.PollIntervalSecs) * time.Second
}
var state *loopState
var err error
if opts.DryRun {
// Dry-run: dedup awareness without leaving any trace on disk.
state, err = openLoopStateReadOnly(c.cfg.Loop.StateDir)
} else {
state, err = openLoopState(c.cfg.Loop.StateDir)
}
if err != nil {
return err
}
defer state.Close()
key, err := c.keys.Resolve(ctx, rc.KeyRef)
if err != nil {
return fmt.Errorf("loop: redmine key: %w", err)
}
writer := writeback.NewRedmineWriter(rc, key)
var gitea *writeback.GiteaWriter
if c.cfg.Gitea.CommitReports {
gkey, err := c.keys.Resolve(ctx, c.cfg.Gitea.KeyRef)
if err != nil {
return fmt.Errorf("loop: gitea key: %w", err)
}
gitea = writeback.NewGiteaWriter(c.cfg.Gitea, gkey)
}
fmt.Fprintf(c.out, "harness: loop: vertical=%s poll=%s state=%s redmine=%s (SIGINT to stop)\n",
c.cfg.Vertical, interval, c.cfg.Loop.StateDir, c.redmineHost())
if gitea != nil {
fmt.Fprintf(c.out, "harness: loop: gitea report commit on (%s/%s)\n", c.cfg.Gitea.Owner, c.cfg.Gitea.Repo)
}
for {
if err := c.scanOnce(ctx, state, writer, gitea, opts.DryRun); err != nil {
// Scan failures (redmine down, decode hiccups) are logged and
// retried on the next tick; only ctx cancel ends the daemon.
fmt.Fprintf(c.out, "harness: loop: scan error: %v (retrying next interval)\n", err)
_ = state.log(loopEvent{Type: evError, Detail: "scan: " + err.Error()})
}
if opts.Once {
return nil
}
select {
case <-ctx.Done():
fmt.Fprintf(c.out, "harness: loop: stopped\n")
return nil
case <-time.After(interval):
}
}
}
// scanOnce runs one intake scan: every task whose (id, updated_on) has not
// been processed gets one bounded turn + writebacks, strictly sequentially.
func (c *Conductor) scanOnce(ctx context.Context, state *loopState, writer *writeback.RedmineWriter, gitea *writeback.GiteaWriter, dryRun bool) error {
tasks, err := c.intake(ctx, OnceOpts{})
if err != nil {
return err
}
fresh := 0
for _, t := range tasks {
if !state.processed(t.ID, t.UpdatedOn) {
fresh++
}
}
fmt.Fprintf(c.out, "harness: loop: scan: %d task(s) in scope, %d new/updated\n", len(tasks), fresh)
for _, t := range tasks {
if state.processed(t.ID, t.UpdatedOn) {
continue
}
if dryRun {
fmt.Fprintf(c.out, "harness: loop: would dispatch %s (updated %s): %q\n", t.ID, t.UpdatedOn, t.Subject)
continue
}
c.dispatchTask(ctx, state, writer, gitea, t)
}
return nil
}
// dispatchTask runs one turn end-to-end for t: mark -> turn -> REPORT ->
// note -> status -> gitea -> refresh. Every step logs one stdout line plus
// one JSONL event; a failed step never kills the loop.
func (c *Conductor) dispatchTask(ctx context.Context, state *loopState, writer *writeback.RedmineWriter, gitea *writeback.GiteaWriter, t task.Task) {
// Mark BEFORE the turn: a failing turn must not hot-loop the poll.
_ = state.log(loopEvent{Type: evDispatch, TaskID: t.ID, UpdatedOn: t.UpdatedOn, Subject: t.Subject})
fmt.Fprintf(c.out, "harness: loop: dispatch %s (updated %s): %q\n", t.ID, t.UpdatedOn, t.Subject)
run, err := c.runTask(ctx, t)
if run != nil && run.turn != nil && run.reportPath != "" {
_ = state.log(loopEvent{
Type: evReport, TaskID: t.ID, Model: run.decision.Model,
ReportPath: run.reportPath, StopReason: run.turn.StopReason,
})
}
if err != nil {
_ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "turn: " + err.Error()})
fmt.Fprintf(c.out, "harness: loop: task %s turn failed: %v (issue left for PMO; update the issue to retry)\n", t.ID, err)
return
}
// Gitea commit (optional): the REPORT file into the repo.
if gitea != nil && run.reportPath != "" {
if err := gitea.CommitFile(ctx, repoPath(run.reportPath), run.reportBody,
fmt.Sprintf("harness: REPORT for %s task %s (%s)", c.cfg.Vertical, t.ID, run.decision.Model)); err != nil {
_ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "gitea commit: " + err.Error()})
fmt.Fprintf(c.out, "harness: loop: gitea commit failed for %s: %v\n", t.ID, err)
} else {
_ = state.log(loopEvent{Type: evCommit, TaskID: t.ID, ReportPath: run.reportPath})
fmt.Fprintf(c.out, "harness: loop: gitea: committed %s\n", repoPath(run.reportPath))
}
}
// SoR writeback: note the REPORT on the issue, then transition status.
if to, ok := c.cfg.Redmine.StatusMap[t.Status]; ok {
if err := writer.SetStatus(ctx, t.ID, to); err != nil {
_ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "status: " + err.Error()})
fmt.Fprintf(c.out, "harness: loop: status transition failed for %s: %v\n", t.ID, err)
} else {
_ = state.log(loopEvent{Type: evStatus, TaskID: t.ID, StatusFrom: t.Status, StatusTo: to})
fmt.Fprintf(c.out, "harness: loop: status %s -> %s on #%s\n", t.Status, to, t.ID)
}
}
if err := writer.AddNote(ctx, t.ID, run.reportBody); err != nil {
_ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "note: " + err.Error()})
fmt.Fprintf(c.out, "harness: loop: note writeback failed for %s: %v\n", t.ID, err)
} else {
_ = state.log(loopEvent{Type: evNote, TaskID: t.ID, ReportPath: run.reportPath})
fmt.Fprintf(c.out, "harness: loop: noted REPORT on #%s\n", t.ID)
}
// Our own writebacks bump updated_on; refresh the dedup marker to the
// post-writeback value so the next scan does not re-trigger itself.
updated, err := writer.UpdatedOn(ctx, t.ID)
if err != nil {
_ = state.log(loopEvent{Type: evError, TaskID: t.ID, Detail: "refresh: " + err.Error()})
fmt.Fprintf(c.out, "harness: loop: refresh failed for %s: %v (a re-dispatch may follow)\n", t.ID, err)
return
}
_ = state.log(loopEvent{Type: evRefresh, TaskID: t.ID, UpdatedOn: updated})
}
// taskRun is one completed (or failed) turn with everything the writebacks
// need: the routing decision, the turn result, and the REPORT file path +
// rendered body.
type taskRun struct {
task task.Task
decision models.Decision
turn *TurnResult
reportPath string
reportBody string
}
// runTask is the shared per-task core of `once` and `loop`: routing ->
// bounded turn -> REPORT file. The returned error is the turn error (a
// partial REPORT may still exist); setup failures (routing, keys) return a
// nil taskRun.
func (c *Conductor) runTask(ctx context.Context, t task.Task) (*taskRun, error) {
decision, err := c.router.Resolve(t.Class)
if err != nil {
return nil, err
}
fmt.Fprintf(c.out, "harness: task %s (%s): %q class=%s -> %s -> %s\n",
t.ID, t.Source, t.Subject, t.Class, decision.Tier, decision.Model)
client, err := c.llmClient()
if err != nil {
return nil, err
}
start := time.Now()
turn, err := c.turn(ctx, client, t, decision.Model)
run := &taskRun{task: t, decision: decision, turn: turn}
if err != nil {
if turn != nil && turn.Content != "" {
path, body, werr := c.writeReport(t, decision, turn, start, err)
if werr == nil {
run.reportPath, run.reportBody = path, body
}
}
return run, fmt.Errorf("%w: %v", ErrLLM, err)
}
path, body, werr := c.writeReport(t, decision, turn, start, nil)
if werr != nil {
return run, werr
}
run.reportPath, run.reportBody = path, body
return run, nil
}
// repoPath maps a local REPORT path to its in-repo path (slash-separated,
// relative to the process CWD when possible, else the base name).
func repoPath(local string) string {
cwd, err := os.Getwd()
if err == nil {
if rel, err := filepath.Rel(cwd, local); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return filepath.ToSlash(rel)
}
}
return filepath.ToSlash(filepath.Base(local))
}
func (c *Conductor) redmineHost() string {
u := c.cfg.Redmine.URL
if i := strings.Index(u, "://"); i >= 0 {
u = u[i+3:]
}
return strings.TrimSuffix(u, "/")
}
+415
View File
@@ -0,0 +1,415 @@
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"])
}
}
+14 -10
View File
@@ -1,6 +1,7 @@
// Package loop is the conductor: one bounded iteration per call
// (intake -> gate -> bounded turn -> REPORT writeback). Callers chain
// iterations by re-invoking; there is no daemon.
// (intake -> gate -> bounded turn -> REPORT writeback) plus the
// self-hosting daemon (`harness loop`) that drives iterations off the
// Redmine intake poll.
package loop
import (
@@ -32,6 +33,7 @@ type Conductor struct {
cfg *config.Config
router *models.Router
bash *tools.BashTool
keys *config.KeyResolver
out io.Writer
}
@@ -55,7 +57,7 @@ func New(cfg *config.Config, out io.Writer) (*Conductor, error) {
return nil, err
}
}
return &Conductor{cfg: cfg, router: router, bash: bash, out: out}, nil
return &Conductor{cfg: cfg, router: router, bash: bash, keys: config.NewKeyResolver(cfg), out: out}, nil
}
// OnceOpts controls a single iteration.
@@ -127,11 +129,11 @@ func (c *Conductor) Once(ctx context.Context, opts OnceOpts) (*OnceResult, error
if err != nil {
// Keep whatever content the turn produced before failing.
if turn != nil && turn.Content != "" {
_, _ = c.writeReport(t, decision, turn, start, err)
_, _, _ = c.writeReport(t, decision, turn, start, err)
}
return res, fmt.Errorf("%w: %v", ErrLLM, err)
}
path, err := c.writeReport(t, decision, turn, start, nil)
path, _, err := c.writeReport(t, decision, turn, start, nil)
if err != nil {
return res, err
}
@@ -227,7 +229,7 @@ func (c *Conductor) intake(ctx context.Context, opts OnceOpts) ([]task.Task, err
if rc.URL == "" {
return nil, fmt.Errorf("%w: no [redmine] url configured (use --demo for the demo issue)", ErrIntake)
}
key, err := config.ResolveKeyRef(rc.KeyRef)
key, err := c.keys.Resolve(ctx, rc.KeyRef)
if err != nil {
return nil, fmt.Errorf("%w: redmine key: %v", ErrIntake, err)
}
@@ -241,7 +243,7 @@ func (c *Conductor) intake(ctx context.Context, opts OnceOpts) ([]task.Task, err
}
func (c *Conductor) llmClient() (*llm.Client, error) {
key, err := config.ResolveKeyRef(c.cfg.LiteLLM.KeyRef)
key, err := c.keys.Resolve(context.Background(), c.cfg.LiteLLM.KeyRef)
if err != nil {
return nil, fmt.Errorf("litellm key: %w", err)
}
@@ -253,7 +255,9 @@ func (c *Conductor) llmClient() (*llm.Client, error) {
), nil
}
func (c *Conductor) writeReport(t task.Task, d models.Decision, turn *TurnResult, start time.Time, turnErr error) (string, error) {
// writeReport persists the turn as a REPORT file and returns its path plus
// the rendered body (the loop re-uses the body for the Redmine note).
func (c *Conductor) writeReport(t task.Task, d models.Decision, turn *TurnResult, start time.Time, turnErr error) (string, string, error) {
r := writeback.Report{
Time: time.Now().UTC(),
Vertical: c.cfg.Vertical,
@@ -276,10 +280,10 @@ func (c *Conductor) writeReport(t task.Task, d models.Decision, turn *TurnResult
}
path, err := writeback.Write(c.cfg.ReportDir, r)
if err != nil {
return "", err
return "", "", err
}
fmt.Fprintf(c.out, "harness: REPORT %s\n", path)
return path, nil
return path, r.Render(), nil
}
func (c *Conductor) printPlan(t task.Task, d models.Decision) {
+144
View File
@@ -0,0 +1,144 @@
package loop
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Loop event types (one JSON line per action in loop.jsonl).
const (
evDispatch = "dispatch" // turn started for task_id at updated_on
evReport = "report" // REPORT file written (or partial on failure)
evNote = "note" // REPORT noted back on the Redmine issue
evStatus = "status" // issue status transitioned per the config map
evCommit = "commit" // REPORT committed to gitea (optional step)
evRefresh = "refresh" // dedup marker advanced to post-writeback updated_on
evError = "error" // a step failed; loop continues
)
// loopEvent is one line of the append-only loop log. It doubles as the
// dedup index source: dispatch/refresh lines carry (task_id, updated_on)
// pairs; the latest one per task wins.
type loopEvent struct {
TS time.Time `json:"ts"`
Type string `json:"type"`
TaskID string `json:"task_id,omitempty"`
UpdatedOn string `json:"updated_on,omitempty"`
Subject string `json:"subject,omitempty"`
Model string `json:"model,omitempty"`
ReportPath string `json:"report_path,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
StatusFrom string `json:"status_from,omitempty"`
StatusTo string `json:"status_to,omitempty"`
Detail string `json:"detail,omitempty"` // human context; never secrets
}
// loopState is the append-only loop log + dedup index: stateDir/loop.jsonl,
// one event per line. The index maps task id -> the updated_on it was last
// processed at; it is rebuilt from the file at open so restarts keep the
// exactly-once-reaction semantics (a torn tail line is skipped, not fatal).
type loopState struct {
mu sync.Mutex
f *os.File
path string
seen map[string]string
readOnly bool // dry-run: dedup checks work, writes are refused
}
func openLoopState(stateDir string) (*loopState, error) {
if err := os.MkdirAll(stateDir, 0o700); err != nil {
return nil, fmt.Errorf("loop state dir: %w", err)
}
path := filepath.Join(stateDir, "loop.jsonl")
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, fmt.Errorf("open loop log: %w", err)
}
s := &loopState{path: path, f: f, seen: make(map[string]string)}
if err := s.loadIndex(); err != nil {
f.Close()
return nil, err
}
return s, nil
}
// openLoopStateReadOnly builds the dedup index without creating or
// touching the log (the dry-run path must leave no trace).
func openLoopStateReadOnly(stateDir string) (*loopState, error) {
s := &loopState{
path: filepath.Join(stateDir, "loop.jsonl"),
seen: make(map[string]string),
readOnly: true,
}
if err := s.loadIndex(); err != nil {
return nil, err
}
return s, nil
}
func (s *loopState) loadIndex() error {
rf, err := os.Open(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read loop log: %w", err)
}
defer rf.Close()
sc := bufio.NewScanner(rf)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
var ev loopEvent
if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
continue // torn/malformed tail line; dedup stays conservative
}
if ev.Type == evDispatch || ev.Type == evRefresh {
s.seen[ev.TaskID] = ev.UpdatedOn
}
}
return sc.Err()
}
// processed reports whether taskID was already handled at this updated_on.
// Empty updated_on (demo/local tasks) dedups on the id alone.
func (s *loopState) processed(taskID, updatedOn string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.seen[taskID] == updatedOn
}
// log appends one event line and, for dispatch/refresh events, advances the
// dedup marker. In read-only mode (dry-run) it is a no-op.
func (s *loopState) log(ev loopEvent) error {
if s.readOnly {
return nil
}
ev.TS = time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
line, err := json.Marshal(ev)
if err != nil {
return fmt.Errorf("encode loop event: %w", err)
}
if _, err := s.f.Write(append(line, '\n')); err != nil {
return fmt.Errorf("append loop log: %w", err)
}
if ev.Type == evDispatch || ev.Type == evRefresh {
s.seen[ev.TaskID] = ev.UpdatedOn
}
return nil
}
func (s *loopState) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.f == nil {
return nil
}
return s.f.Close()
}
+8
View File
@@ -9,4 +9,12 @@ type Task struct {
Prompt string `json:"prompt"`
Class string `json:"class"`
Source string `json:"source"` // "redmine" | "demo"
// UpdatedOn is the Redmine `updated_on` at intake. The `harness loop`
// dedups on issue id + updated_on: an issue update re-releases the
// task; an unchanged issue is never re-dispatched.
UpdatedOn string `json:"updated_on,omitempty"`
// Status is the Redmine status NAME at intake ("In Progress", ...);
// the loop's status map keys off it. Empty for non-Redmine tasks.
Status string `json:"status,omitempty"`
}
+125
View File
@@ -0,0 +1,125 @@
// Gitea REPORT commit (optional, [gitea] commit_reports = true): right
// after a REPORT file lands, the loop commits it to the configured repo via
// the contents API (create, or update with the existing blob sha). All
// endpoints come from config; nothing host-specific lives in code.
package writeback
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"ukrrs.com/mopac/harness/internal/config"
)
// GiteaWriter commits files to one configured repo.
type GiteaWriter struct {
url string
key string
owner string
repo string
branch string
http *http.Client
}
// NewGiteaWriter builds the client from config and a resolved key.
func NewGiteaWriter(cfg config.GiteaConfig, key string) *GiteaWriter {
return &GiteaWriter{
url: strings.TrimSuffix(cfg.URL, "/"),
key: key,
owner: cfg.Owner,
repo: cfg.Repo,
branch: cfg.Branch,
http: &http.Client{Timeout: 30 * time.Second},
}
}
// CommitFile creates or updates path in the repo with content. If the file
// already exists its blob sha is fetched and the update carried out.
func (w *GiteaWriter) CommitFile(ctx context.Context, path, content, message string) error {
api := w.url + "/api/v1/repos/" + w.owner + "/" + w.repo + "/contents/" + path
var sha string
body, err := w.do(ctx, http.MethodGet, api+"?ref="+url.QueryEscape(w.branch), nil)
if err == nil {
var cur struct {
SHA string `json:"sha"`
Type string `json:"type"`
}
if json.Unmarshal(body, &cur) == nil && cur.Type == "file" {
sha = cur.SHA
}
} else if !isNotFound(err) {
return err // GET failed for a reason other than "does not exist"
}
payload := map[string]any{
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
}
if w.branch != "" {
payload["branch"] = w.branch
}
method := http.MethodPost
if sha != "" {
payload["sha"] = sha
method = http.MethodPut
}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
if _, err := w.do(ctx, method, api, raw); err != nil {
return err
}
return nil
}
func (w *GiteaWriter) do(ctx context.Context, method, api string, body []byte) ([]byte, error) {
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, api, rd)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "token "+w.key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := w.http.Do(req)
if err != nil {
return nil, fmt.Errorf("gitea request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, fmt.Errorf("gitea read: %w", err)
}
if resp.StatusCode == http.StatusNotFound {
return nil, errNotFound
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("gitea HTTP %d: %s", resp.StatusCode, truncateFor(string(raw), 300))
}
return raw, nil
}
type notFoundError struct{}
func (notFoundError) Error() string { return "gitea: not found" }
var errNotFound error = notFoundError{}
func isNotFound(err error) bool {
_, ok := err.(notFoundError)
return ok
}
+172
View File
@@ -0,0 +1,172 @@
// Redmine writeback: after a turn, the loop notes the REPORT back on the
// issue (journal note via PUT /issues/{id}.json) and moves the issue status
// per the configured [redmine.status_map] (names resolved to ids via
// /issue_statuses.json). Intake stays read-only; all writes live here.
package writeback
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"ukrrs.com/mopac/harness/internal/config"
)
// RedmineWriter is the write side of the SoR: notes + status transitions.
type RedmineWriter struct {
url string
key string
http *http.Client
mu sync.Mutex
statusIDs map[string]int // status name -> id, cached per process
}
// NewRedmineWriter builds the write client from config and a resolved key.
func NewRedmineWriter(cfg config.RedmineConfig, key string) *RedmineWriter {
return &RedmineWriter{
url: strings.TrimSuffix(cfg.URL, "/"),
key: key,
http: &http.Client{Timeout: 30 * time.Second},
statusIDs: nil, // fetched lazily on first status transition
}
}
// AddNote appends a journal note to the issue (the REPORT lands in the SoR).
func (w *RedmineWriter) AddNote(ctx context.Context, issueID, note string) error {
var body struct {
Issue struct {
Notes string `json:"notes"`
} `json:"issue"`
}
body.Issue.Notes = note
return w.putIssue(ctx, issueID, body)
}
// SetStatus moves the issue to the named status. The name is resolved to an
// id via /issue_statuses.json (fetched once, cached); unknown names error.
func (w *RedmineWriter) SetStatus(ctx context.Context, issueID, statusName string) error {
id, err := w.statusID(ctx, statusName)
if err != nil {
return err
}
var body struct {
Issue struct {
StatusID int `json:"status_id"`
} `json:"issue"`
}
body.Issue.StatusID = id
return w.putIssue(ctx, issueID, body)
}
// UpdatedOn fetches the issue's current updated_on. The loop calls it right
// after its own writebacks (which bump updated_on) so the dedup marker
// reflects the post-writeback state instead of re-triggering itself.
func (w *RedmineWriter) UpdatedOn(ctx context.Context, issueID string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, w.url+"/issues/"+issueID+".json", nil)
if err != nil {
return "", err
}
raw, err := w.do(req)
if err != nil {
return "", err
}
var payload struct {
Issue struct {
UpdatedOn string `json:"updated_on"`
} `json:"issue"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return "", fmt.Errorf("redmine decode: %w", err)
}
return payload.Issue.UpdatedOn, nil
}
func (w *RedmineWriter) statusID(ctx context.Context, name string) (int, error) {
w.mu.Lock()
if w.statusIDs != nil {
id, ok := w.statusIDs[strings.ToLower(name)]
w.mu.Unlock()
if !ok {
return 0, fmt.Errorf("redmine status %q not found in /issue_statuses.json", name)
}
return id, nil
}
w.mu.Unlock()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, w.url+"/issue_statuses.json", nil)
if err != nil {
return 0, err
}
raw, err := w.do(req)
if err != nil {
return 0, err
}
var payload struct {
Statuses []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"issue_statuses"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return 0, fmt.Errorf("redmine decode statuses: %w", err)
}
ids := make(map[string]int, len(payload.Statuses))
for _, st := range payload.Statuses {
ids[strings.ToLower(st.Name)] = st.ID
}
w.mu.Lock()
w.statusIDs = ids
w.mu.Unlock()
id, ok := ids[strings.ToLower(name)]
if !ok {
return 0, fmt.Errorf("redmine status %q not found in /issue_statuses.json", name)
}
return id, nil
}
func (w *RedmineWriter) putIssue(ctx context.Context, issueID string, body any) error {
raw, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, w.url+"/issues/"+issueID+".json", bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if _, err := w.do(req); err != nil {
return err
}
return nil
}
func (w *RedmineWriter) do(req *http.Request) ([]byte, error) {
req.Header.Set("X-Redmine-API-Key", w.key)
resp, err := w.http.Do(req)
if err != nil {
return nil, fmt.Errorf("redmine request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, fmt.Errorf("redmine read: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("redmine HTTP %d: %s", resp.StatusCode, truncateFor(string(raw), 300))
}
return raw, nil
}
func truncateFor(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+155
View File
@@ -0,0 +1,155 @@
package writeback
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"ukrrs.com/mopac/harness/internal/config"
)
func TestRedmineWriterNoteAndStatus(t *testing.T) {
var mu sync.Mutex
var puts []struct {
path string
body string
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Redmine-API-Key"); got != "rm-key" {
http.Error(w, "bad key", http.StatusUnauthorized)
return
}
switch {
case r.Method == http.MethodPut && r.URL.Path == "/issues/421.json":
raw, _ := io.ReadAll(r.Body)
mu.Lock()
puts = append(puts, struct{ path, body string }{r.URL.Path, string(raw)})
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodGet && r.URL.Path == "/issue_statuses.json":
w.Write([]byte(`{"issue_statuses":[{"id":2,"name":"In Progress"},{"id":3,"name":"Done"}]}`))
case r.Method == http.MethodGet && r.URL.Path == "/issues/421.json":
w.Write([]byte(`{"issue":{"id":421,"updated_on":"2026-08-28T22:31:11Z"}}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
w := NewRedmineWriter(config.RedmineConfig{URL: srv.URL}, "rm-key")
if err := w.AddNote(context.Background(), "421", "REPORT body here"); err != nil {
t.Fatalf("AddNote: %v", err)
}
if err := w.SetStatus(context.Background(), "421", "Done"); err != nil {
t.Fatalf("SetStatus: %v", err)
}
updated, err := w.UpdatedOn(context.Background(), "421")
if err != nil || updated != "2026-08-28T22:31:11Z" {
t.Fatalf("UpdatedOn = %q err=%v", updated, err)
}
mu.Lock()
defer mu.Unlock()
if len(puts) != 2 {
t.Fatalf("PUTs = %d, want 2 (note + status)", len(puts))
}
var noteBody struct {
Issue struct {
Notes string `json:"notes"`
} `json:"issue"`
}
if err := json.Unmarshal([]byte(puts[0].body), &noteBody); err != nil {
t.Fatalf("note PUT body: %v", err)
}
if noteBody.Issue.Notes != "REPORT body here" {
t.Errorf("note body = %+v", noteBody)
}
var statusBody struct {
Issue struct {
StatusID int `json:"status_id"`
} `json:"issue"`
}
if err := json.Unmarshal([]byte(puts[1].body), &statusBody); err != nil {
t.Fatalf("status PUT body: %v", err)
}
if statusBody.Issue.StatusID != 3 {
t.Errorf("status_id = %d, want 3 (Done)", statusBody.Issue.StatusID)
}
}
func TestRedmineWriterUnknownStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/issue_statuses.json" {
w.Write([]byte(`{"issue_statuses":[{"id":3,"name":"Done"}]}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
w := NewRedmineWriter(config.RedmineConfig{URL: srv.URL}, "k")
err := w.SetStatus(context.Background(), "421", "Nonexistent")
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("err = %v, want unknown-status error", err)
}
}
func TestGiteaWriterCreateAndUpdate(t *testing.T) {
var mu sync.Mutex
sha := ""
var methods []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "token gt-key" {
http.Error(w, "bad token", http.StatusUnauthorized)
return
}
if !strings.HasPrefix(r.URL.Path, "/api/v1/repos/ukrrs/reports/contents/") {
http.NotFound(w, r)
return
}
mu.Lock()
defer mu.Unlock()
switch r.Method {
case http.MethodGet:
if sha == "" {
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
return
}
w.Write([]byte(`{"sha":"` + sha + `","type":"file"}`))
case http.MethodPost, http.MethodPut:
methods = append(methods, r.Method)
raw, _ := io.ReadAll(r.Body)
var body map[string]any
json.Unmarshal(raw, &body)
if body["branch"] != "main" {
t.Errorf("branch = %v, want main", body["branch"])
}
if r.Method == http.MethodPut && body["sha"] == nil {
t.Errorf("update PUT must carry the existing sha")
}
sha = "newsha"
w.Write([]byte(`{"content":{"path":"reports/x.md"}}`))
}
}))
defer srv.Close()
w := NewGiteaWriter(config.GiteaConfig{
URL: srv.URL, Owner: "ukrrs", Repo: "reports", Branch: "main",
}, "gt-key")
if err := w.CommitFile(context.Background(), "reports/x.md", "hello", "first"); err != nil {
t.Fatalf("create: %v", err)
}
if err := w.CommitFile(context.Background(), "reports/x.md", "hello again", "second"); err != nil {
t.Fatalf("update: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(methods) != 2 || methods[0] != http.MethodPost || methods[1] != http.MethodPut {
t.Errorf("methods = %v, want [POST PUT] (create then update)", methods)
}
}