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
+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) {