114 lines
3.0 KiB
Go
114 lines
3.0 KiB
Go
package agents
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io/fs"
|
||
|
|
"log/slog"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"path/filepath"
|
||
|
|
"strconv"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"g.fc.run/misc/byre/internal/api"
|
||
|
|
"g.fc.run/misc/byre/internal/claude"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
setupScript = "script/setup"
|
||
|
|
setupTimeout = 15 * time.Minute
|
||
|
|
setupOutputBytes = 64 << 10
|
||
|
|
)
|
||
|
|
|
||
|
|
func portEnv(t api.Task) []string {
|
||
|
|
return []string{"PORT=" + strconv.Itoa(t.PortBase)}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) startSetup(t api.Task) {
|
||
|
|
path := filepath.Join(t.WorktreePath, setupScript)
|
||
|
|
_, err := os.Stat(path)
|
||
|
|
if errors.Is(err, fs.ErrNotExist) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
m.setupMu.Lock()
|
||
|
|
m.setups[t.Id] = true
|
||
|
|
m.setupMu.Unlock()
|
||
|
|
|
||
|
|
go m.runSetup(t, path)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) settingUp(taskID string) bool {
|
||
|
|
m.setupMu.Lock()
|
||
|
|
defer m.setupMu.Unlock()
|
||
|
|
return m.setups[taskID]
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) takeSetupFailure(taskID string) string {
|
||
|
|
m.setupMu.Lock()
|
||
|
|
defer m.setupMu.Unlock()
|
||
|
|
msg := m.setupFailures[taskID]
|
||
|
|
delete(m.setupFailures, taskID)
|
||
|
|
return msg
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) runSetup(t api.Task, path string) {
|
||
|
|
ctx := context.Background()
|
||
|
|
m.emitTask(ctx, t, "setup.started", map[string]any{"script": setupScript, "port_base": t.PortBase})
|
||
|
|
|
||
|
|
start := time.Now()
|
||
|
|
cctx, cancel := context.WithTimeout(ctx, setupTimeout)
|
||
|
|
defer cancel()
|
||
|
|
cmd := exec.CommandContext(cctx, path)
|
||
|
|
cmd.Dir = t.WorktreePath
|
||
|
|
cmd.Env = append(os.Environ(), portEnv(t)...)
|
||
|
|
out, err := cmd.CombinedOutput()
|
||
|
|
if cctx.Err() != nil {
|
||
|
|
err = fmt.Errorf("timed out after %s", setupTimeout)
|
||
|
|
}
|
||
|
|
|
||
|
|
output := string(out)
|
||
|
|
if len(out) > setupOutputBytes {
|
||
|
|
output = "…" + string(out[len(out)-setupOutputBytes:])
|
||
|
|
}
|
||
|
|
payload := map[string]any{"script": setupScript, "output": output, "duration_ms": time.Since(start).Milliseconds()}
|
||
|
|
if err != nil {
|
||
|
|
payload["error"] = err.Error()
|
||
|
|
}
|
||
|
|
slog.Info("setup ended", "task", t.Id, "err", err, "took", time.Since(start))
|
||
|
|
m.emitTask(ctx, t, "setup.ended", payload)
|
||
|
|
|
||
|
|
m.setupMu.Lock()
|
||
|
|
delete(m.setups, t.Id)
|
||
|
|
if err != nil {
|
||
|
|
m.setupFailures[t.Id] = fmt.Sprintf("byre ran %s in this worktree when the task was created, and it failed (%s). Its output:\n\n```\n%s\n```\n\nThe worktree may be missing dependencies or env files the setup would have provided.", setupScript, err, output)
|
||
|
|
}
|
||
|
|
m.setupMu.Unlock()
|
||
|
|
|
||
|
|
err = m.drain(ctx)
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("start queued turns", "err", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) emitTask(ctx context.Context, t api.Task, kind string, payload map[string]any) {
|
||
|
|
err := m.store.AppendEvent(ctx, api.Event{Kind: kind, ProjectId: &t.ProjectId, TaskId: &t.Id, Actor: "byre", Payload: payload})
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("record setup event", "task", t.Id, "kind", kind, "err", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) remindTaskGuidance(l *live, text string) claude.HookFunc {
|
||
|
|
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||
|
|
failure := m.takeSetupFailure(l.taskID)
|
||
|
|
if failure == "" {
|
||
|
|
return remindGuidance(text)(ctx, input)
|
||
|
|
}
|
||
|
|
m.fileError(l, failure)
|
||
|
|
return remindGuidance(text+"\n\n"+failure)(ctx, input)
|
||
|
|
}
|
||
|
|
}
|