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:
+14
-10
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user