Files
byre/internal/agents/snapshots.go
T

313 lines
8.6 KiB
Go

package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/git"
"g.fc.run/ai/byre/internal/hookcmd"
"g.fc.run/ai/byre/internal/ids"
"g.fc.run/ai/byre/internal/mcp"
"g.fc.run/ai/byre/internal/store"
)
type snapRequest struct {
target snapTarget
trigger api.SnapshotTrigger
seq int64
review bool
force bool
}
type snapTarget struct {
taskID string
projectID string
sessionID string
dir string
gitName string
gitEmail string
}
func liveTarget(l *live) snapTarget {
return snapTarget{taskID: l.taskID, projectID: l.projectID, sessionID: l.id, dir: l.dir, gitName: l.gitName, gitEmail: l.gitEmail}
}
type snapState struct {
mu sync.Mutex
running bool
pending *snapRequest
closed bool
}
func (m *Manager) snapStateLocked(taskID string) *snapState {
st := m.snaps[taskID]
if st == nil {
st = &snapState{}
m.snaps[taskID] = st
}
return st
}
func (m *Manager) snapshotSoon(t snapTarget, trigger api.SnapshotTrigger, seq int64, review, force bool) {
m.snapMu.Lock()
defer m.snapMu.Unlock()
st := m.snapStateLocked(t.taskID)
if st.closed {
m.dropRequest(t.taskID, &snapRequest{review: review})
return
}
req := &snapRequest{target: t, trigger: trigger, seq: seq, review: review, force: force}
if st.running {
if p := st.pending; p != nil && p.force {
req.force = true
req.trigger = p.trigger
}
m.dropRequest(t.taskID, st.pending)
st.pending = req
return
}
st.running = true
go m.runSnapshots(st, req)
}
func (m *Manager) runSnapshots(st *snapState, req *snapRequest) {
for {
t := req.target
unlock := m.lockTask(t.taskID)
st.mu.Lock()
m.snapMu.Lock()
closed := st.closed
m.snapMu.Unlock()
if !closed {
snap, err := m.takeSnapshot(context.Background(), t, req.trigger, req.seq, req.force, req.review)
if err != nil {
slog.Error("take snapshot", "task", t.taskID, "trigger", req.trigger, "err", err)
}
if snap == nil {
m.dropRequest(t.taskID, req)
}
} else {
m.dropRequest(t.taskID, req)
}
st.mu.Unlock()
unlock()
m.snapMu.Lock()
req = st.pending
st.pending = nil
if req == nil || st.closed {
m.dropRequest(t.taskID, req)
st.running = false
m.snapMu.Unlock()
return
}
m.snapMu.Unlock()
}
}
func (m *Manager) dropRequest(taskID string, req *snapRequest) {
if req != nil && req.review {
m.endReviewRequest(taskID)
}
}
func (m *Manager) endReviewRequest(taskID string) {
err := m.store.EndReviewRequest(context.Background(), taskID)
if err != nil {
slog.Error("end review request", "task", taskID, "err", err)
}
}
func (m *Manager) takeSnapshot(ctx context.Context, t snapTarget, trigger api.SnapshotTrigger, seq int64, force, review bool) (*api.Snapshot, error) {
if seq == 0 {
var err error
seq, err = m.store.LastTaskSeq(ctx, t.taskID)
if err != nil {
return nil, err
}
}
tree, err := git.Snapshot(ctx, t.dir)
if err != nil {
return nil, err
}
last, err := m.store.LastSnapshot(ctx, t.taskID)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return nil, err
}
reviewed, err := m.store.LastReviewable(ctx, t.taskID)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return nil, err
}
if err == nil && reviewed.TreeSha == tree && !force {
return nil, nil
}
n := last.N + 1
commit, err := git.CommitSnapshot(ctx, t.dir, tree, git.SnapshotRef(t.taskID, n), fmt.Sprintf("byre snapshot #%d (%s)", n, trigger), git.IdentityEnv(t.gitName, t.gitEmail))
if err != nil {
return nil, err
}
snap := api.Snapshot{
Id: ids.New(ids.Snapshot),
TaskId: t.taskID,
AgentSessionId: t.sessionID,
N: n,
CommitSha: commit,
TreeSha: tree,
EventSeq: seq,
Trigger: trigger,
CreatedAt: time.Now().UTC(),
}
err = m.store.CreateSnapshot(ctx, snap, t.projectID, review)
if err != nil {
return nil, err
}
return &snap, nil
}
func (m *Manager) RequestSnapshot(ctx context.Context, taskID string) error {
task, _, err := m.activeTask(ctx, taskID)
if err != nil {
return err
}
discard, err := m.store.GetDiscard(ctx, task.Id)
if err != nil {
return err
}
if discard.State != store.DiscardNone {
return fmt.Errorf("%w: task %s is being discarded", ErrConflict, task.Id)
}
merging, err := m.store.MergeTurnPending(ctx, task.Id)
if err != nil {
return err
}
if merging {
return fmt.Errorf("%w: task %s is merging", ErrConflict, task.Id)
}
sessions, err := m.store.ListSessions(ctx, task.Id)
if err != nil {
return err
}
if len(sessions) == 0 {
return fmt.Errorf("%w: task %s has no agent session to attribute a snapshot to", ErrConflict, task.Id)
}
t, err := m.sessionTarget(ctx, sessions[len(sessions)-1], task)
if err != nil {
return err
}
unlock := m.lockTask(task.Id)
_, err = git.CommitAll(ctx, t.dir, "Work before take_snapshot", t.gitName, t.gitEmail)
unlock()
if err != nil {
return err
}
err = m.store.BeginReviewRequest(ctx, task.Id)
if err != nil {
return err
}
m.snapshotSoon(t, api.SnapshotTriggerRequested, 0, true, true)
return nil
}
func (m *Manager) sessionTarget(ctx context.Context, sess api.AgentSession, task api.Task) (snapTarget, error) {
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
if err != nil {
return snapTarget{}, err
}
return snapTarget{taskID: task.Id, projectID: task.ProjectId, sessionID: sess.Id, dir: task.WorktreePath, gitName: creator.GitName, gitEmail: creator.GitEmail}, nil
}
func (m *Manager) snapshotForAnswer(ctx context.Context, t snapTarget, held bool) int {
snap, err := m.cutSnapshot(ctx, t, held, api.SnapshotTriggerAnswer)
if err != nil {
slog.Error("take snapshot", "task", t.taskID, "trigger", api.SnapshotTriggerAnswer, "err", err)
m.recordError(&live{id: t.sessionID, taskID: t.taskID, projectID: t.projectID}, fmt.Sprintf("byre couldn't snapshot the worktree before handing over an answer, so the answer went to the agent without one: %v", err))
return 0
}
if snap == nil {
return 0
}
return snap.N
}
func (m *Manager) cutSnapshot(ctx context.Context, t snapTarget, held bool, trigger api.SnapshotTrigger) (*api.Snapshot, error) {
if !held {
unlock := m.lockTask(t.taskID)
defer unlock()
}
m.snapMu.Lock()
st := m.snapStateLocked(t.taskID)
m.snapMu.Unlock()
st.mu.Lock()
defer st.mu.Unlock()
m.snapMu.Lock()
closed := st.closed
m.snapMu.Unlock()
if closed {
return nil, nil
}
return m.takeSnapshot(ctx, t, trigger, 0, true, false)
}
func (m *Manager) takeSnapshotTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "take_snapshot",
Description: takeSnapshotDescription + " It snapshots your own task's worktree.",
InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`),
Handler: func(ctx context.Context, args json.RawMessage) (string, error) {
err := m.RequestSnapshot(ctx, l.taskID)
if err != nil {
return "", err
}
return "Worktree committed to the task's branch, and snapshot requested. If the project has a review workflow, its reviewers check it against the default branch; their findings come to you together once the workflow finishes.", nil
},
}
}
const takeSnapshotDescription = "Commit and snapshot a task's worktree now: byre commits the whole worktree, uncommitted work included, to the task's branch, and returns once that commit is made. It then snapshots that commit on byre's own ref for the task (refs/byre/tasks/<task>/snap/<n>). If the project has a review workflow, its reviewers check all of the task's work against the default branch, using each stage's model and instructions; findings arrive together once the workflow finishes. " +
"byre already does this at the end of every turn that changes files; call this to review work mid-turn, or to retry a review that failed (it snapshots even when nothing changed since the last snapshot)."
func (m *Manager) dropSnapshots(repo, taskID string) error {
m.snapMu.Lock()
st := m.snapStateLocked(taskID)
st.closed = true
m.snapMu.Unlock()
st.mu.Lock()
defer st.mu.Unlock()
return git.DeleteSnapshotRefs(context.Background(), repo, taskID)
}
func (m *Manager) afterTool(l *live) hookcmd.Func {
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
in := struct {
HookEventName string `json:"hook_event_name"`
}{}
err := json.Unmarshal(input, &in)
if err != nil {
return nil, err
}
m.holdWhilePaused(ctx, l, hookToolUseID(input))
mt := m.deliverMidTurn(ctx, l)
if mt.text == "" {
return map[string]any{}, nil
}
return map[string]any{
"hookSpecificOutput": map[string]any{
"hookEventName": in.HookEventName,
"additionalContext": mt.text,
},
}, nil
}
}