Run the merge queue on a state-driven coordinator

This commit is contained in:
Ian Gulliver
2026-10-21 11:28:07 -07:00
parent a971cce9d6
commit 7fbdf1669a
13 changed files with 509 additions and 561 deletions
+6
View File
@@ -84,6 +84,12 @@ Vendor setup and protocol quirks are in [Claude Code](claude-protocol.md), [Code
- An audit checks only the newest commit of the default branch, one at a time per project, so it doesn't run on every revision but the newest one is always audited eventually. byre never fetches for an audit, so a commit pushed outside byre is audited only once a byre merge has fetched it.
- A team task that comes up with some members failing to start isn't repaired; discard it and create it again.
## Coordination
Merges to the default branch run on a coordinator: a single goroutine (`internal/agents/coordinator.go`) that every stored event wakes, and that decides only from the database's current state, never from which event woke it. Its decisions run inside one store write transaction, so no other write lands while it decides. Slow work (git, starting a turn) runs after that transaction, and shows in state while it runs (a session `running`, a task `finalizing`), so the next pass doesn't start it twice. Startup is just one more pass, with no separate recovery code. Other task progress (discards, reviews, nudges, workflow stages, lead delivery) is driven by the events themselves.
A project's merge slot is held by a task with `merging_by`, `finalizing` or `pushing_to_main` set. A Merge request takes the slot at once when it's free and nothing is queued, else queues (`merge_queued_by`). Each pass drops a merge whose turn ended without finalizing, gives a free slot to the oldest queued task, starts a claimed merge turn (`merge_after_turn`) once none of the task's agents is mid-turn, and lands each finalizing merge. A session that hands off to a fresh context stays `running` until the new session takes over, so no pass sees the task idle in between.
## git
- Merge notes live under `refs/notes/byre`. byre adds the fetch refspec `refs/notes/byre*:refs/notes/byre*` to each checkout; it's a pattern because git refuses a fetch whose exact refspec names a ref the remote doesn't have yet, as it doesn't before the first note is pushed. Another clone adds it with `git config --add remote.origin.fetch 'refs/notes/byre*:refs/notes/byre*'`; `git log --notes=byre` shows the notes.
+22 -18
View File
@@ -69,9 +69,13 @@ func isContinuation(actor, text string) bool {
func (m *Manager) continueFromHandoff(sessionID, handoff string) {
ctx := context.Background()
err := m.handoff(ctx, sessionID, handoff)
if err != nil {
slog.Error("continue from handoff", "session", sessionID, "err", err)
m.advanceSessionMergeClaim(ctx, sessionID)
if err == nil {
return
}
slog.Error("continue from handoff", "session", sessionID, "err", err)
err = m.store.TransitionSession(ctx, sessionID, api.AgentSessionStateRunning, api.AgentSessionStateIdle, "byre")
if err != nil && !errors.Is(err, store.ErrStateChanged) {
slog.Error("end failed handoff", "session", sessionID, "err", err)
}
}
@@ -95,28 +99,28 @@ func (m *Manager) handoff(ctx context.Context, sessionID, handoff string) error
return err
}
discard, err := m.store.GetDiscard(ctx, sess.TaskId)
if err != nil {
return err
if creator.GitName == "" || creator.GitEmail == "" {
return ErrNoGitIdentity
}
if discard.State != store.DiscardNone {
m.startDiscard(sess.TaskId)
return nil
}
err = transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateCompleted, "byre")
if err != nil {
return err
}
m.stopLive(sess.Id)
task, err := m.store.GetTask(ctx, sess.TaskId)
if err != nil {
return err
}
_, err = m.create(ctx, task, creator, sess.Member, handoffPreamble+handoff, model, "byre", "", "", nil)
if errors.Is(err, ErrConflict) {
next := sessionFor(task, creator, sess.Member, model)
next.State = api.AgentSessionStateRunning
err = m.store.ReplaceSession(ctx, sess.Id, next, task.ProjectId, "byre")
if errors.Is(err, store.ErrStateChanged) {
err = transition(ctx, m.store, sess.Id, api.AgentSessionStateRunning, api.AgentSessionStateIdle, "byre")
if err != nil {
return err
}
m.startDiscard(task.Id)
return nil
}
if err != nil {
return err
}
m.stopLive(sess.Id)
_, err = m.launch(ctx, next, task, "byre", handoffPreamble+handoff, "", "", nil)
return err
}
+57
View File
@@ -0,0 +1,57 @@
package agents
import (
"context"
"log/slog"
)
const abandonedMergeText = "The Merge turn ended unfinished (interrupted, failed to start, or with no final message), so byre didn't squash or push the task. Merge again to retry."
func (m *Manager) poke() {
select {
case m.pokes <- struct{}{}:
default:
}
}
func (m *Manager) coordinate() {
finalizing := map[string]bool{}
for {
select {
case <-m.watchStop:
return
case <-m.pokes:
m.coordinateMerges(context.Background(), finalizing)
}
}
}
func (m *Manager) coordinateMerges(ctx context.Context, finalizing map[string]bool) {
work, err := m.store.CoordinateMerges(ctx)
if err != nil {
slog.Error("coordinate merges", "err", err)
return
}
for _, id := range work.Abandoned {
slog.Info("merge turn ended unfinished", "task", id)
m.fileError(ctx, id, abandonedMergeText)
m.deliverIdle(ctx, id)
}
for _, c := range work.Start {
m.startClaimedMerge(ctx, c)
}
current := map[string]bool{}
for _, f := range work.Finalize {
current[f.Task.Id] = true
if finalizing[f.Task.Id] {
continue
}
finalizing[f.Task.Id] = true
go m.finalizeMerge(f)
}
for id := range finalizing {
if !current[id] {
delete(finalizing, id)
}
}
}
+3
View File
@@ -363,6 +363,9 @@ func (m *Manager) warnLeadContext(ctx context.Context, l *live) error {
func (m *Manager) Observe(events []api.Event) {
batch := []api.Event{}
for _, ev := range events {
if ev.Seq != 0 {
m.poke()
}
if to, _ := ev.Payload["to"].(string); ev.Kind == "task.state" && readyState(api.TaskState(to)) && ev.TaskId != nil {
go m.promptReady(*ev.TaskId, ev.Payload["from"])
}
+25 -67
View File
@@ -74,8 +74,7 @@ type Manager struct {
snapMu sync.Mutex
snaps map[string]*snapState
mergeMu sync.Mutex
merges map[string]*projectMerge
pokes chan struct{}
reviewMu sync.Mutex
@@ -169,7 +168,7 @@ func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome, antigrav
landing: map[string]landing{},
signals: make(chan []api.Event, 1024),
snaps: map[string]*snapState{},
merges: map[string]*projectMerge{},
pokes: make(chan struct{}, 1),
previewListeners: map[int]*previewListener{},
bad: newBadLines(),
watchStop: make(chan struct{}),
@@ -236,9 +235,15 @@ func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.Use
}
}
sess := sessionFor(task, creator, member, model)
err = m.store.CreateSession(ctx, sess, task.ProjectId)
return sess, err
}
func sessionFor(task api.Task, creator api.User, member, model string) api.AgentSession {
id := ids.New(ids.Session)
vendor := vendorOf(model)
sess := api.AgentSession{
return api.AgentSession{
Id: id,
TaskId: task.Id,
Vendor: vendor,
@@ -250,9 +255,6 @@ func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.Use
CreatedBy: creator.Login,
CreatedAt: time.Now().UTC(),
}
err = m.store.CreateSession(ctx, sess, task.ProjectId)
return sess, err
}
func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model, sendID, client string, images ...string) (api.AgentSession, error) {
@@ -354,12 +356,7 @@ func (m *Manager) stopLive(sessionID string) {
}
func (m *Manager) Recover(ctx context.Context) error {
err := m.recoverMerges(ctx)
if err != nil {
return err
}
err = m.recoverFinalizing(ctx)
err := m.store.ClearMainPushes(ctx)
if err != nil {
return err
}
@@ -470,8 +467,8 @@ func (m *Manager) Recover(ctx context.Context) error {
go m.deliverAllLeads()
go m.deliverAllQueued(context.Background())
go m.advanceMergeClaims(context.Background())
go m.startHeldMerges(context.Background())
go m.coordinate()
m.poke()
go m.recoverMergeAfterReviews(context.Background())
go m.auditAll(context.Background())
return nil
@@ -489,16 +486,11 @@ func (m *Manager) failResume(ctx context.Context, sess api.AgentSession, task ap
if serr != nil {
return serr
}
switch {
case end.Discard == store.DiscardClosing:
switch end.Discard {
case store.DiscardClosing:
go m.finishDiscard(task.Id)
case end.Discard == store.DiscardHeld:
case store.DiscardHeld:
go m.startDiscard(task.Id)
case end.MergeBy != "":
go m.advanceMergeClaim(task.Id)
case end.MergeWaiting:
case sess.Role == api.Implementer && m.leadsTask(ctx, task.Id, sess.Id):
m.abandonMerge(task.Id, task.ProjectId)
}
return nil
}
@@ -683,18 +675,6 @@ func (m *Manager) failStart(ctx context.Context, sessionID, actor string, err er
if serr != nil {
slog.Error("record failed start", "session", sessionID, "err", serr)
}
go m.advanceSessionMergeClaim(context.Background(), sessionID)
}
func (m *Manager) advanceSessionMergeClaim(ctx context.Context, sessionID string) {
sess, err := m.store.GetSession(ctx, sessionID)
if err != nil {
slog.Error("load session for merge claim", "session", sessionID, "err", err)
return
}
if sess.TaskId != "" {
m.advanceMergeClaim(sess.TaskId)
}
}
func (m *Manager) syncModel(ctx context.Context, l *live, model string) error {
@@ -1031,13 +1011,7 @@ func (m *Manager) pump(l *live) {
m.startDiscard(l.taskID)
return
}
if end.MergeBy != "" {
go m.advanceMergeClaim(l.taskID)
}
if l.owner == "" {
if end.MergeBy == "" && !end.MergeWaiting && m.leadsTask(ctx, l.taskID, l.id) {
m.abandonMerge(l.taskID, l.projectID)
}
m.recordError(l, "The agent process exited mid-turn. Send a message to resume it.")
}
}
@@ -1223,13 +1197,8 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
payload["member"] = l.member
}
owner := m.ownsTask(l.id)
keep := false
if owner {
defer func() {
if !keep {
m.endMergeTurn(l.id, false)
}
}()
defer m.endMergeTurn(l.id, false)
}
if (owner || m.landingBy(l.taskID, l.id) != "") && !finished {
err := git.AbortMerge(l.dir)
@@ -1337,10 +1306,6 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
if snapshot && !held {
m.snapshotSoon(liveTarget(l), api.SnapshotTriggerTurnEnd, 0, review, false)
}
if end.MergeBy != "" {
go m.advanceMergeClaim(l.taskID)
return nil
}
switch end.Discard {
case store.DiscardClosing:
@@ -1348,7 +1313,6 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
go m.finishDiscard(l.taskID)
return nil
case store.DiscardHeld:
go m.nextMerge(l.projectID)
if stageFinished {
go m.advanceStage(l)
return nil
@@ -1363,23 +1327,8 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
}
if end.FinalizeBy != "" {
m.logUndelivered(ctx, l.id, "turn ended in merge")
keep = owner
go m.finishMerge(l, end.FinalizeBy)
return nil
}
mergeBy := ""
if !end.MergeWaiting && m.leadsTask(ctx, l.taskID, l.id) {
mergeBy = m.takeMerge(l.taskID)
}
switch {
case mergeBy != "":
go m.releaseMerge(l.projectID, l.taskID)
if !res.isError || interrupted {
m.recordError(l, "The Merge turn ended unfinished (interrupted, or with no final message), so byre didn't squash or push the task. Merge again to retry.")
}
default:
go m.nextMerge(l.projectID)
}
if stageFinished {
go m.advanceStage(l)
return nil
@@ -1398,6 +1347,15 @@ func (m *Manager) afterTurn(ctx context.Context, l *live) {
m.startDiscard(l.taskID)
return
}
task, err := m.store.GetTask(ctx, l.taskID)
if err != nil {
slog.Error("read task after turn", "task", l.taskID, "err", err)
return
}
if task.Merging {
m.logUndelivered(ctx, l.id, "task is merging")
return
}
m.deliverTeam(ctx, l)
if m.interrupted(l.id) {
m.logUndelivered(ctx, l.id, "turn was interrupted")
+101 -280
View File
@@ -8,7 +8,6 @@ import (
"log/slog"
"os"
"strings"
"sync"
"unicode/utf8"
"g.fc.run/ai/byre/internal/api"
@@ -87,13 +86,6 @@ func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project,
return api.Task{}, err
}
pm := m.projectMerge(project.Id)
pm.mu.Lock()
defer pm.mu.Unlock()
if pm.holder == task.Id {
return api.Task{}, fmt.Errorf("%w: task %s is already merging", ErrConflict, task.Id)
}
if title != task.MergeTitle {
err = m.storeMergeTitle(ctx, task.Id, title, user.Login)
if err != nil {
@@ -102,111 +94,41 @@ func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project,
task.MergeTitle = title
}
switch pm.holder {
case "":
err := m.startMerge(ctx, pm, task, project, user)
if err != nil {
return api.Task{}, err
}
default:
err := m.store.QueueMerge(ctx, task.Id, user.Login)
if errors.Is(err, store.ErrNotFound) {
return api.Task{}, fmt.Errorf("%w: task %s is closed, being discarded or already waiting to merge", ErrConflict, task.Id)
}
if err != nil {
return api.Task{}, err
}
held, err := m.store.RequestMerge(ctx, task.Id, user.Login)
switch {
case errors.Is(err, store.ErrNotFound):
return api.Task{}, fmt.Errorf("%w: task %s is closed, being discarded or already waiting to merge", ErrConflict, task.Id)
case errors.Is(err, store.ErrMerging):
return api.Task{}, fmt.Errorf("%w: task %s is already merging", ErrConflict, task.Id)
case err != nil:
return api.Task{}, err
}
if held {
m.headStartMerge(ctx, task, user, project.DefaultBranch)
}
m.clearMergeAfterReview(ctx, task.Id)
return m.store.GetTask(ctx, task.Id)
}
type projectMerge struct {
mu sync.Mutex
holder string
}
func (m *Manager) projectMerge(projectID string) *projectMerge {
m.mergeMu.Lock()
defer m.mergeMu.Unlock()
pm := m.merges[projectID]
if pm == nil {
pm = &projectMerge{}
m.merges[projectID] = pm
}
return pm
}
func (m *Manager) startMerge(ctx context.Context, pm *projectMerge, task api.Task, project api.Project, user api.User) error {
err := m.store.SetMergingBy(ctx, task.Id, user.Login)
if errors.Is(err, store.ErrNotFound) {
return fmt.Errorf("%w: task %s is being discarded", ErrConflict, task.Id)
}
if err != nil {
return err
}
pm.holder = task.Id
err = m.beginMergeTurn(ctx, task, project, user)
if err != nil {
m.takeMerge(task.Id)
pm.holder = ""
}
return err
}
func (m *Manager) beginMergeTurn(ctx context.Context, task api.Task, project api.Project, user api.User) error {
sess, err := m.mergeSession(ctx, task, user)
if err != nil {
return err
}
go m.launchMerge(sess, task, project, mergeTurnPrompt(project))
return nil
}
func mergeTurnPrompt(project api.Project) string {
return mergeInPrompt(project.DefaultBranch) + fmt.Sprintf(" When this turn ends, byre squashes this branch onto %s and pushes it; if you stop, call stop_merge first so it doesn't. ", project.DefaultBranch) + summaryRequest
}
func (m *Manager) advanceMergeClaims(ctx context.Context) {
ids, err := m.store.MergeClaims(ctx)
if err != nil {
slog.Error("list merge claims", "err", err)
return
}
for _, id := range ids {
m.advanceMergeClaim(id)
}
}
func (m *Manager) advanceMergeClaim(taskID string) {
ctx := context.Background()
err := m.beginClaimedMerge(ctx, taskID)
func (m *Manager) startClaimedMerge(ctx context.Context, c store.ClaimedMerge) {
err := m.beginClaimedMerge(ctx, c)
if err == nil {
return
}
slog.Error("start claimed merge", "task", taskID, "err", err)
task, terr := m.store.GetTask(ctx, taskID)
slog.Error("start claimed merge", "task", c.TaskID, "err", err)
_, terr := m.store.TakeMergingBy(ctx, c.TaskID)
if terr != nil {
slog.Error("load task for claimed merge", "task", taskID, "err", terr)
return
slog.Error("drop claimed merge", "task", c.TaskID, "err", terr)
}
m.abandonMerge(taskID, task.ProjectId)
sessions, serr := m.store.ListSessions(ctx, taskID)
if serr != nil {
slog.Error("list sessions for claimed merge", "task", taskID, "err", serr)
return
}
m.fileTaskError(ctx, task, sessions, "Starting the held merge failed: "+err.Error())
m.fileError(ctx, c.TaskID, "Starting the merge failed: "+err.Error())
}
func (m *Manager) beginClaimedMerge(ctx context.Context, taskID string) error {
login, err := m.store.MergeClaim(ctx, taskID)
if err != nil || login == "" {
return err
}
task, err := m.store.GetTask(ctx, taskID)
func (m *Manager) beginClaimedMerge(ctx context.Context, c store.ClaimedMerge) error {
task, err := m.store.GetTask(ctx, c.TaskID)
if err != nil {
return err
}
@@ -214,7 +136,7 @@ func (m *Manager) beginClaimedMerge(ctx context.Context, taskID string) error {
if err != nil {
return err
}
user, err := m.store.GetUser(ctx, login)
user, err := m.store.GetUser(ctx, c.By)
if err != nil {
return err
}
@@ -229,7 +151,7 @@ func (m *Manager) beginClaimedMerge(ctx context.Context, taskID string) error {
if err != nil || !reserved {
return err
}
slog.Info("started claimed merge", "task", taskID, "by", login)
slog.Info("started claimed merge", "task", task.Id, "by", c.By)
go m.launchMerge(sess, task, project, mergeTurnPrompt(project))
return nil
}
@@ -259,7 +181,6 @@ func (m *Manager) launchMerge(sess api.AgentSession, task api.Task, project api.
}
slog.Error("start merge turn", "task", task.Id, "err", err)
m.failStart(ctx, sess.Id, "byre", err)
m.abandonMerge(task.Id, project.Id)
}
func (m *Manager) deliverMerge(ctx context.Context, sess api.AgentSession, task api.Task, project api.Project, prompt string) error {
@@ -296,73 +217,6 @@ func (m *Manager) deliverMerge(ctx context.Context, sess api.AgentSession, task
return m.send(ctx, l, prompt, nil)
}
func (m *Manager) recoverMerges(ctx context.Context) error {
merging, err := m.store.MergingTasks(ctx)
if err != nil {
return err
}
for _, t := range merging {
pm := m.projectMerge(t.ProjectId)
pm.mu.Lock()
pm.holder = t.Id
pm.mu.Unlock()
}
return nil
}
func (m *Manager) recoverFinalizing(ctx context.Context) error {
finalizing, err := m.store.FinalizingMerges(ctx)
if err != nil {
return err
}
for _, f := range finalizing {
ferr := m.resumeFinalizing(ctx, f)
if ferr == nil {
slog.Info("finished merge at startup", "task", f.Task.Id, "commit", f.Commit)
continue
}
slog.Error("finish merge at startup", "task", f.Task.Id, "err", ferr)
unfinished := &unfinishedMergeError{}
if !errors.As(ferr, &unfinished) {
err = m.store.ClearFinalizing(ctx, f.Task.Id)
if err != nil {
return err
}
}
sessions, err := m.store.ListSessions(ctx, f.Task.Id)
if err != nil {
return err
}
m.fileTaskError(ctx, f.Task, sessions, "byre restarted while finishing this task's merge: "+ferr.Error()+".")
}
return nil
}
func (m *Manager) resumeFinalizing(ctx context.Context, f store.FinalizingMerge) error {
project, err := m.store.GetProject(ctx, f.Task.ProjectId)
if err != nil {
return err
}
if f.Unchanged {
return m.closeUnchanged(ctx, f.Task, project, f.By)
}
if f.Commit == "" {
return errors.New("nothing was pushed. Merge again to retry")
}
landed, err := pushLanded(ctx, project, f.Commit)
if err != nil {
return err
}
if !landed {
return fmt.Errorf("its squash commit %s is not on %s/%s, so the push didn't land. Merge again to retry", f.Commit, git.Remote, project.DefaultBranch)
}
return m.closePushed(ctx, f.Task, project, f.Commit, f.Notes, f.By)
}
func pushLanded(ctx context.Context, project api.Project, commit string) (bool, error) {
target := git.Remote + "/" + project.DefaultBranch
err := git.Fetch(ctx, project.RepoPath)
@@ -388,67 +242,18 @@ func (e *unfinishedMergeError) Unwrap() error {
return e.err
}
func (m *Manager) startHeldMerges(ctx context.Context) {
projects, err := m.store.ProjectsWithQueuedMerges(ctx)
func (m *Manager) fileError(ctx context.Context, taskID, msg string) {
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
slog.Error("list projects with held merges", "err", err)
slog.Error("load task to file error on", "task", taskID, "error", msg, "err", err)
return
}
for _, p := range projects {
m.nextMerge(p)
}
}
func (m *Manager) abandonMerge(taskID, projectID string) {
if m.takeMerge(taskID) == "" {
return
}
go m.releaseMerge(projectID, taskID)
}
func (m *Manager) releaseMerge(projectID, taskID string) {
pm := m.projectMerge(projectID)
pm.mu.Lock()
defer pm.mu.Unlock()
if pm.holder == taskID {
pm.holder = ""
}
m.startNextMerge(pm, projectID)
}
func (m *Manager) nextMerge(projectID string) {
pm := m.projectMerge(projectID)
pm.mu.Lock()
defer pm.mu.Unlock()
m.startNextMerge(pm, projectID)
}
func (m *Manager) startNextMerge(pm *projectMerge, projectID string) {
if pm.holder != "" {
return
}
ctx := context.Background()
held, err := m.store.QueuedMerges(ctx, projectID)
sessions, err := m.store.ListSessions(ctx, taskID)
if err != nil {
slog.Error("list held merges", "project", projectID, "err", err)
return
}
for _, task := range held {
login, err := m.store.ClaimQueuedMerge(ctx, task.Id)
if err != nil {
slog.Error("take held merge", "task", task.Id, "err", err)
continue
}
if login == "" {
continue
}
pm.holder = task.Id
slog.Info("claimed held merge", "task", task.Id, "by", login)
go m.advanceMergeClaim(task.Id)
slog.Error("list sessions to file error on", "task", taskID, "error", msg, "err", err)
return
}
m.fileTaskError(ctx, task, sessions, msg)
}
func (m *Manager) fileTaskError(ctx context.Context, task api.Task, sessions []api.AgentSession, msg string) {
@@ -460,85 +265,100 @@ func (m *Manager) fileTaskError(ctx context.Context, task api.Task, sessions []a
m.recordError(&live{id: last.Id, taskID: task.Id, projectID: task.ProjectId}, msg)
}
func (m *Manager) takeMerge(taskID string) string {
login, err := m.store.TakeMergingBy(context.Background(), taskID)
if err != nil {
slog.Error("take merge intent", "task", taskID, "err", err)
}
return login
}
func (m *Manager) finishMerge(l *live, login string) {
defer m.releaseMerge(l.projectID, l.taskID)
func (m *Manager) finalizeMerge(f store.FinalizingMerge) {
ctx := context.Background()
err := m.squashAndClose(ctx, l.taskID, login)
m.endMergeTurn(l.id, false)
unlock := m.lockTask(f.Task.Id)
err := m.landMerge(ctx, f)
unlock()
if err == nil {
return
}
slog.Error("finish merge", "task", l.taskID, "err", err)
slog.Error("finish merge", "task", f.Task.Id, "err", err)
unfinished := &unfinishedMergeError{}
if errors.As(err, &unfinished) {
m.recordError(l, "Merging the task failed: "+err.Error()+".")
m.fileError(ctx, f.Task.Id, "Merging the task failed: "+err.Error()+".")
return
}
cerr := m.store.ClearFinalizing(ctx, l.taskID)
cerr := m.store.ClearFinalizing(ctx, f.Task.Id)
if cerr != nil {
slog.Error("clear merge finalizing", "task", l.taskID, "err", cerr)
slog.Error("clear merge finalizing", "task", f.Task.Id, "err", cerr)
}
m.recordError(l, "Merging the task failed: "+err.Error())
err = m.Deliver(ctx, l.id)
m.fileError(ctx, f.Task.Id, "Merging the task failed: "+err.Error())
m.deliverIdle(ctx, f.Task.Id)
}
func (m *Manager) deliverIdle(ctx context.Context, taskID string) {
sessions, err := m.store.ListSessions(ctx, taskID)
if err != nil {
slog.Error("deliver inbox responses", "session", l.id, "err", err)
slog.Error("list sessions to deliver to", "task", taskID, "err", err)
return
}
for _, s := range sessions {
if s.Role != api.Implementer || s.State != api.AgentSessionStateIdle || m.interrupted(s.Id) {
continue
}
err = m.Deliver(ctx, s.Id)
if err != nil {
slog.Error("deliver inbox responses", "session", s.Id, "err", err)
}
}
}
func (m *Manager) squashAndClose(ctx context.Context, taskID, login string) error {
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
return err
}
func (m *Manager) landMerge(ctx context.Context, f store.FinalizingMerge) error {
task := f.Task
project, err := m.store.GetProject(ctx, task.ProjectId)
if err != nil {
return err
}
user, err := m.store.GetUser(ctx, login)
if err != nil {
return err
if f.Unchanged {
return m.closeUnchanged(ctx, task, project, f.By)
}
coAuthors, err := m.CoAuthors(ctx, taskID, login)
if err != nil {
return err
}
env := git.IdentityEnv(user.GitName, user.GitEmail)
commit, err := git.SquashCommit(project.RepoPath, task.WorktreePath, project.DefaultBranch, squashMessage(task.MergeTitle, coAuthors), env, m.signer(ctx, user))
if err != nil {
return err
}
if commit == "" {
err = m.store.SetFinalizingUnchanged(ctx, taskID)
commit, notes := f.Commit, f.Notes
if commit != "" {
landed, err := pushLanded(ctx, project, commit)
if err != nil {
return err
}
return m.closeUnchanged(ctx, task, project, login)
if landed {
return m.closePushed(ctx, task, project, commit, notes, f.By)
}
}
if commit == "" {
user, err := m.store.GetUser(ctx, f.By)
if err != nil {
return err
}
coAuthors, err := m.CoAuthors(ctx, task.Id, f.By)
if err != nil {
return err
}
env := git.IdentityEnv(user.GitName, user.GitEmail)
commit, err = git.SquashCommit(project.RepoPath, task.WorktreePath, project.DefaultBranch, squashMessage(task.MergeTitle, coAuthors), env, m.signer(ctx, user))
if err != nil {
return err
}
if commit == "" {
err = m.store.SetFinalizingUnchanged(ctx, task.Id)
if err != nil {
return err
}
return m.closeUnchanged(ctx, task, project, f.By)
}
note, err := m.mergeNote(ctx, task, login)
if err != nil {
return err
}
notes, err := git.NoteCommit(project.RepoPath, commit, note, env)
if err != nil {
return err
}
err = m.store.SetFinalizingCommit(ctx, taskID, commit, notes)
if err != nil {
return err
note, err := m.mergeNote(ctx, task, f.By)
if err != nil {
return err
}
notes, err = git.NoteCommit(project.RepoPath, commit, note, env)
if err != nil {
return err
}
err = m.store.SetFinalizingCommit(ctx, task.Id, commit, notes)
if err != nil {
return err
}
}
err = git.Push(ctx, project.RepoPath, commit, project.DefaultBranch, notes)
if errors.Is(err, git.ErrRemoteMoved) {
@@ -552,9 +372,9 @@ func (m *Manager) squashAndClose(ctx context.Context, taskID, login string) erro
if !landed {
return err
}
slog.Warn("push reported failure but landed", "task", taskID, "commit", commit, "err", err)
slog.Warn("push reported failure but landed", "task", task.Id, "commit", commit, "err", err)
}
return m.closePushed(ctx, task, project, commit, notes, login)
return m.closePushed(ctx, task, project, commit, notes, f.By)
}
func (m *Manager) closeUnchanged(ctx context.Context, task api.Task, project api.Project, login string) error {
@@ -749,10 +569,11 @@ func (m *Manager) stopMerge(ctx context.Context, l *live, raw json.RawMessage) (
if !m.leadsTask(ctx, l.taskID, l.id) {
return "", fmt.Errorf("%w: only the team's first agent runs the merge, so only it can stop it", ErrConflict)
}
switch {
case m.takeMerge(l.taskID) != "":
go m.releaseMerge(l.projectID, l.taskID)
default:
mergingBy, err := m.store.TakeMergingBy(ctx, l.taskID)
if err != nil {
return "", err
}
if mergingBy == "" {
queuedBy, err := m.store.TakeQueuedMerge(ctx, l.taskID)
if err != nil {
return "", err
+12 -7
View File
@@ -81,17 +81,14 @@ func (m *Manager) mergeToMain(ctx context.Context, l *live, raw json.RawMessage)
return "", err
}
pm := m.projectMerge(project.Id)
pm.mu.Lock()
holder := pm.holder
if holder == "" {
pm.holder = task.Id
holder, err := m.store.ClaimMainPush(ctx, task.Id)
if err != nil {
return "", err
}
pm.mu.Unlock()
if holder != "" {
return "", fmt.Errorf("%w: task %s is merging into %s; call again once it is done", ErrConflict, holder, project.DefaultBranch)
}
defer m.releaseMerge(project.Id, task.Id)
defer m.endMainPush(task.Id)
commit, err := m.pushToMain(ctx, l, task, project, user, title)
if err != nil {
@@ -170,6 +167,14 @@ func (m *Manager) pushToMain(ctx context.Context, l *live, task api.Task, projec
return commit, nil
}
func (m *Manager) endMainPush(taskID string) {
err := m.store.EndMainPush(context.Background(), taskID)
if err != nil {
slog.Error("end push to main", "task", taskID, "err", err)
}
m.poke()
}
func landPrompt(target, title string) string {
return fmt.Sprintf("The human asked to merge this task's work so far into %[1]s and keep the task open (Merge & continue); byre holds the task's other agents until this turn ends. "+
"First merge %[1]s in with `git merge --no-ff --no-commit %[1]s`, resolve any conflicts keeping both sides' intent, regenerate generated files instead of hand-merging them, and check that the build and tests pass. "+
+2 -2
View File
@@ -188,8 +188,8 @@ func (m *Manager) subtaskTools(l *live) []mcp.Tool {
tool("merge_subtask",
"Merge a subtask. target parent: byre sends you a message asking you to merge the subtask's branch into this task's branch, "+
"and closes the subtask once its branch is in this branch's history at the end of one of your turns. "+
"target main: the subtask's agent merges the default branch in, then byre squashes it onto the default branch, pushes it and closes it, as the human's Merge does. "+
"Refused while the subtask's agent is mid-turn.",
"target main: the subtask's agent merges the default branch in, then byre squashes it onto the default branch, pushes it and closes it, as the human's Merge does; it starts once the subtask's agent finishes any turn it is in. "+
"target parent is refused while the subtask's agent is mid-turn.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"target": {"type": "string", "enum": ["parent", "main"]}
+1 -5
View File
@@ -262,11 +262,7 @@ func (m *Manager) discardNow(ctx context.Context, taskID string) error {
return err
}
_, err = m.store.DiscardTask(ctx, task.Id, discard.By)
if err != nil {
return err
}
go m.releaseMerge(project.Id, task.Id)
return nil
return err
}
func (m *Manager) recoverDiscards(ctx context.Context) error {
+19 -26
View File
@@ -116,10 +116,8 @@ func (s *Store) StartDiscardTurn(ctx context.Context, taskID, sessionID, actor s
}
type TurnEnd struct {
Discard DiscardState
FinalizeBy string
MergeBy string
MergeWaiting bool
Discard DiscardState
FinalizeBy string
}
func (s *Store) EndTurn(ctx context.Context, sessionID, actor string, payload map[string]any, turn *api.Event, reviewRequest, finalizeMerge, continues bool) (TurnEnd, error) {
@@ -132,14 +130,10 @@ func (s *Store) EndTurn(ctx context.Context, sessionID, actor string, payload ma
state := DiscardNone
discardSession := ""
lead := false
mergeAfterTurn := false
othersRunning := false
err := tx.QueryRowContext(ctx, `
SELECT coalesce(t.id, ''), coalesce(t.project_id, ''), s.owner, s.role, coalesce(t.discard_state, ''), coalesce(t.discard_session, ''),
s.member = coalesce((SELECT f.member FROM agent_sessions f WHERE f.task_id = s.task_id AND f.role = s.role ORDER BY f.created_at LIMIT 1), ''),
coalesce(t.merge_after_turn, 0),
EXISTS (SELECT 1 FROM agent_sessions o WHERE o.task_id = s.task_id AND o.role = s.role AND o.state = ? AND o.id != s.id)
FROM agent_sessions s LEFT JOIN tasks t ON t.id = s.task_id WHERE s.id = ?`, string(api.AgentSessionStateRunning), sessionID).Scan(&taskID, &projectID, &owner, &role, &state, &discardSession, &lead, &mergeAfterTurn, &othersRunning)
s.member = coalesce((SELECT f.member FROM agent_sessions f WHERE f.task_id = s.task_id AND f.role = s.role ORDER BY f.created_at LIMIT 1), '')
FROM agent_sessions s LEFT JOIN tasks t ON t.id = s.task_id WHERE s.id = ?`, sessionID).Scan(&taskID, &projectID, &owner, &role, &state, &discardSession, &lead)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
@@ -159,17 +153,20 @@ func (s *Store) EndTurn(ctx context.Context, sessionID, actor string, payload ma
return err
}
}
_, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET state = ? WHERE id = ?`, string(api.AgentSessionStateIdle), sessionID)
if err != nil {
return err
}
if payload == nil {
payload = map[string]any{}
}
payload["state"] = string(api.AgentSessionStateIdle)
err = emit(sessionEvent("session.state", projectID, taskID, owner, sessionID, actor, payload))
if err != nil {
return err
handsOff := continues && role == string(api.Implementer) && state == DiscardNone
if !handsOff {
_, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET state = ? WHERE id = ?`, string(api.AgentSessionStateIdle), sessionID)
if err != nil {
return err
}
if payload == nil {
payload = map[string]any{}
}
payload["state"] = string(api.AgentSessionStateIdle)
err = emit(sessionEvent("session.state", projectID, taskID, owner, sessionID, actor, payload))
if err != nil {
return err
}
}
if role != string(api.Implementer) {
@@ -181,12 +178,8 @@ func (s *Store) EndTurn(ctx context.Context, sessionID, actor string, payload ma
case state == DiscardRunning && discardSession == sessionID:
end.Discard = DiscardClosing
_, err = tx.ExecContext(ctx, `UPDATE tasks SET discard_state = ? WHERE id = ?`, string(DiscardClosing), taskID)
case mergeAfterTurn && (othersRunning || continues):
end.MergeWaiting = true
case mergeAfterTurn:
err = tx.QueryRowContext(ctx, `SELECT merging_by FROM tasks WHERE id = ?`, taskID).Scan(&end.MergeBy)
case state == DiscardNone && finalizeMerge && lead:
err = tx.QueryRowContext(ctx, `UPDATE tasks SET finalizing = merging_by, merging_by = '' WHERE id = ? AND merging_by != '' RETURNING finalizing`, taskID).Scan(&end.FinalizeBy)
err = tx.QueryRowContext(ctx, `UPDATE tasks SET finalizing = merging_by, merging_by = '' WHERE id = ? AND merging_by != '' AND merge_after_turn = 0 RETURNING finalizing`, taskID).Scan(&end.FinalizeBy)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
+225
View File
@@ -0,0 +1,225 @@
package store
import (
"context"
"database/sql"
"errors"
"g.fc.run/ai/byre/internal/api"
)
const mergeSlotHolder = `o.project_id = tasks.project_id AND o.status = 'active' AND (o.merging_by != '' OR o.finalizing != '' OR o.pushing_to_main = 1)`
const implementerRunning = `EXISTS (SELECT 1 FROM agent_sessions s WHERE s.task_id = tasks.id AND s.role = 'implementer' AND s.state = 'running')`
func (s *Store) RequestMerge(ctx context.Context, id, login string) (bool, error) {
held := false
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
projectID, status, discardState, queuedBy, mergingBy, finalizing := "", "", "", "", "", ""
err := tx.QueryRowContext(ctx, `
SELECT project_id, status, discard_state, merge_queued_by, merging_by, finalizing,
EXISTS (SELECT 1 FROM tasks o WHERE `+mergeSlotHolder+`)
OR EXISTS (SELECT 1 FROM tasks o WHERE o.project_id = tasks.project_id AND o.status = 'active' AND o.merge_queued_by != '')
FROM tasks WHERE id = ?`, id).Scan(&projectID, &status, &discardState, &queuedBy, &mergingBy, &finalizing, &held)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
switch {
case status != string(api.TaskStatusActive) || discardState != "" || queuedBy != "":
return ErrNotFound
case mergingBy != "" || finalizing != "":
return ErrMerging
}
if held {
_, err = tx.ExecContext(ctx, `UPDATE tasks SET merge_queued_by = ?, merge_queued_at = ? WHERE id = ?`, login, now(), id)
if err != nil {
return err
}
return emit(api.Event{Kind: "task.merge_queued", ProjectId: &projectID, TaskId: &id, Actor: login, Payload: map[string]any{"id": id}})
}
_, err = tx.ExecContext(ctx, `UPDATE tasks SET merging_by = ?, merge_after_turn = 1 WHERE id = ?`, login, id)
if err != nil {
return err
}
return emit(api.Event{Kind: "task.merge_started", ProjectId: &projectID, TaskId: &id, Actor: login, Payload: map[string]any{"id": id}})
})
return held, err
}
type ClaimedMerge struct {
TaskID string
By string
}
type FinalizingMerge struct {
Task api.Task
By string
Commit string
Notes string
Unchanged bool
}
type MergeWork struct {
Abandoned []string
Start []ClaimedMerge
Finalize []FinalizingMerge
}
func (s *Store) CoordinateMerges(ctx context.Context) (MergeWork, error) {
work := MergeWork{}
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
var err error
work.Abandoned, err = abandonMerges(ctx, tx, emit)
if err != nil {
return err
}
err = claimHeldMerges(ctx, tx, emit)
if err != nil {
return err
}
work.Start, err = startableMerges(ctx, tx)
if err != nil {
return err
}
work.Finalize, err = finalizingMerges(ctx, tx)
return err
})
return work, err
}
type idRow struct {
id string
project string
by string
}
func queryIDRows(ctx context.Context, tx *sql.Tx, query string) ([]idRow, error) {
rows, err := tx.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
found := []idRow{}
for rows.Next() {
r := idRow{}
err = rows.Scan(&r.id, &r.project, &r.by)
if err != nil {
return nil, err
}
found = append(found, r)
}
return found, rows.Err()
}
func abandonMerges(ctx context.Context, tx *sql.Tx, emit emitFunc) ([]string, error) {
found, err := queryIDRows(ctx, tx, `SELECT id, project_id, merging_by FROM tasks
WHERE status = 'active' AND merging_by != '' AND merge_after_turn = 0 AND NOT `+implementerRunning)
if err != nil {
return nil, err
}
ids := []string{}
for _, r := range found {
_, err = tx.ExecContext(ctx, `UPDATE tasks SET merging_by = '' WHERE id = ?`, r.id)
if err != nil {
return nil, err
}
err = emit(api.Event{Kind: "task.merge_ended", ProjectId: &r.project, TaskId: &r.id, Actor: "byre", Payload: map[string]any{"id": r.id}})
if err != nil {
return nil, err
}
ids = append(ids, r.id)
}
return ids, nil
}
func claimHeldMerges(ctx context.Context, tx *sql.Tx, emit emitFunc) error {
found, err := queryIDRows(ctx, tx, `SELECT id, project_id, merge_queued_by FROM tasks
WHERE status = 'active' AND merge_queued_by != '' AND discard_state = ''
AND NOT EXISTS (SELECT 1 FROM tasks o WHERE `+mergeSlotHolder+`)
AND NOT EXISTS (SELECT 1 FROM tasks o WHERE o.project_id = tasks.project_id AND o.status = 'active' AND o.merge_queued_by != ''
AND (o.merge_queued_at < tasks.merge_queued_at OR (o.merge_queued_at = tasks.merge_queued_at AND o.id < tasks.id)))`)
if err != nil {
return err
}
for _, r := range found {
_, err = tx.ExecContext(ctx, `UPDATE tasks SET merge_queued_by = '', merge_queued_at = '', merging_by = ?, merge_after_turn = 1 WHERE id = ?`, r.by, r.id)
if err != nil {
return err
}
err = emit(api.Event{Kind: "task.merge_unqueued", ProjectId: &r.project, TaskId: &r.id, Actor: "byre", Payload: map[string]any{"id": r.id}})
if err != nil {
return err
}
err = emit(api.Event{Kind: "task.merge_started", ProjectId: &r.project, TaskId: &r.id, Actor: r.by, Payload: map[string]any{"id": r.id}})
if err != nil {
return err
}
}
return nil
}
func startableMerges(ctx context.Context, tx *sql.Tx) ([]ClaimedMerge, error) {
found, err := queryIDRows(ctx, tx, `SELECT id, project_id, merging_by FROM tasks
WHERE status = 'active' AND merge_after_turn = 1 AND NOT `+implementerRunning)
if err != nil {
return nil, err
}
claims := []ClaimedMerge{}
for _, r := range found {
claims = append(claims, ClaimedMerge{TaskID: r.id, By: r.by})
}
return claims, nil
}
func finalizingMerges(ctx context.Context, tx *sql.Tx) ([]FinalizingMerge, error) {
rows, err := tx.QueryContext(ctx, `SELECT `+taskColumns+`, finalizing, finalizing_commit, finalizing_notes, finalizing_unchanged FROM tasks WHERE status = 'active' AND finalizing != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
merges := []FinalizingMerge{}
for rows.Next() {
f := FinalizingMerge{}
f.Task, err = scanTask(trailingScanner{rows, []any{&f.By, &f.Commit, &f.Notes, &f.Unchanged}})
if err != nil {
return nil, err
}
merges = append(merges, f)
}
return merges, rows.Err()
}
func (s *Store) ClaimMainPush(ctx context.Context, id string) (string, error) {
holder := ""
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
err := tx.QueryRowContext(ctx, `SELECT coalesce((SELECT o.id FROM tasks o WHERE `+mergeSlotHolder+` LIMIT 1), '') FROM tasks WHERE id = ?`, id).Scan(&holder)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil || holder != "" {
return err
}
_, err = tx.ExecContext(ctx, `UPDATE tasks SET pushing_to_main = 1 WHERE id = ?`, id)
return err
})
return holder, err
}
func (s *Store) EndMainPush(ctx context.Context, id string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `UPDATE tasks SET pushing_to_main = 0 WHERE id = ?`, id)
return err
})
}
func (s *Store) ClearMainPushes(ctx context.Context) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `UPDATE tasks SET pushing_to_main = 0 WHERE pushing_to_main = 1`)
return err
})
}
@@ -0,0 +1,2 @@
ALTER TABLE tasks ADD COLUMN pushing_to_main INTEGER NOT NULL DEFAULT 0;
CREATE INDEX tasks_status ON tasks (status);
+34 -156
View File
@@ -459,20 +459,6 @@ func (s *Store) SetTitle(ctx context.Context, id, title, actor string) error {
})
}
func (s *Store) SetMergingBy(ctx context.Context, id, login string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
projectID := ""
err := tx.QueryRowContext(ctx, `UPDATE tasks SET merging_by = ? WHERE id = ? AND discard_state = '' AND finalizing = '' RETURNING project_id`, login, id).Scan(&projectID)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
return emit(api.Event{Kind: "task.merge_started", ProjectId: &projectID, TaskId: &id, Actor: login, Payload: map[string]any{"id": id}})
})
}
func (s *Store) MergingBy(ctx context.Context, id string) (string, error) {
login := ""
err := s.db.QueryRowContext(ctx, `SELECT merging_by FROM tasks WHERE id = ?`, id).Scan(&login)
@@ -545,61 +531,6 @@ func (s *Store) setFinalizing(ctx context.Context, id, commit, notes string, unc
})
}
type FinalizingMerge struct {
Task api.Task
By string
Commit string
Notes string
Unchanged bool
}
func (s *Store) FinalizingMerges(ctx context.Context) ([]FinalizingMerge, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+taskColumns+`, finalizing, finalizing_commit, finalizing_notes, finalizing_unchanged FROM tasks WHERE status = ? AND finalizing != ''`, string(api.TaskStatusActive))
if err != nil {
return nil, err
}
defer rows.Close()
merges := []FinalizingMerge{}
for rows.Next() {
f := FinalizingMerge{}
f.Task, err = scanTask(trailingScanner{rows, []any{&f.By, &f.Commit, &f.Notes, &f.Unchanged}})
if err != nil {
return nil, err
}
merges = append(merges, f)
}
return merges, rows.Err()
}
func (s *Store) MergingTasks(ctx context.Context) ([]api.Task, error) {
return s.queryTasks(ctx, `SELECT `+taskColumns+` FROM tasks WHERE status = ? AND merging_by != ''`, string(api.TaskStatusActive))
}
func (s *Store) QueuedMerges(ctx context.Context, projectID string) ([]api.Task, error) {
return s.queryTasks(ctx, `SELECT `+taskColumns+` FROM tasks WHERE project_id = ? AND status = ? AND merge_queued_by != '' ORDER BY merge_queued_at`,
projectID, string(api.TaskStatusActive))
}
func (s *Store) ProjectsWithQueuedMerges(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT project_id FROM tasks WHERE status = ? AND merge_queued_by != ''`, string(api.TaskStatusActive))
if err != nil {
return nil, err
}
defer rows.Close()
ids := []string{}
for rows.Next() {
id := ""
err = rows.Scan(&id)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *Store) queryTasks(ctx context.Context, query string, args ...any) ([]api.Task, error) {
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
@@ -674,21 +605,6 @@ func (s *Store) ParentMergeBy(ctx context.Context, id string) (string, error) {
return login, err
}
func (s *Store) QueueMerge(ctx context.Context, id, login string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
projectID := ""
err := tx.QueryRowContext(ctx, `UPDATE tasks SET merge_queued_by = ?, merge_queued_at = ? WHERE id = ? AND status = ? AND merge_queued_by = '' AND discard_state = '' RETURNING project_id`,
login, now(), id, string(api.TaskStatusActive)).Scan(&projectID)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
return emit(api.Event{Kind: "task.merge_queued", ProjectId: &projectID, TaskId: &id, Actor: login, Payload: map[string]any{"id": id}})
})
}
func (s *Store) TakeQueuedMerge(ctx context.Context, id string) (string, error) {
login := ""
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
@@ -712,54 +628,6 @@ func (s *Store) TakeQueuedMerge(ctx context.Context, id string) (string, error)
return login, err
}
func (s *Store) ClaimQueuedMerge(ctx context.Context, id string) (string, error) {
login := ""
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
projectID := ""
err := tx.QueryRowContext(ctx, `SELECT project_id, merge_queued_by FROM tasks WHERE id = ?`, id).Scan(&projectID, &login)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if login == "" {
return nil
}
_, err = tx.ExecContext(ctx, `UPDATE tasks SET merge_queued_by = '', merge_queued_at = '' WHERE id = ?`, id)
if err != nil {
return err
}
err = emit(api.Event{Kind: "task.merge_unqueued", ProjectId: &projectID, TaskId: &id, Actor: "byre", Payload: map[string]any{"id": id}})
if err != nil {
return err
}
res, err := tx.ExecContext(ctx, `UPDATE tasks SET merging_by = ?, merge_after_turn = 1 WHERE id = ? AND discard_state = '' AND finalizing = ''`, login, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
login = ""
return nil
}
return emit(api.Event{Kind: "task.merge_started", ProjectId: &projectID, TaskId: &id, Actor: login, Payload: map[string]any{"id": id}})
})
return login, err
}
func (s *Store) MergeClaim(ctx context.Context, id string) (string, error) {
login := ""
err := s.db.QueryRowContext(ctx, `SELECT merging_by FROM tasks WHERE id = ? AND merge_after_turn = 1`, id).Scan(&login)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return login, err
}
func (s *Store) ReserveMergeTurn(ctx context.Context, id, sessionID string) (bool, error) {
reserved := false
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
@@ -784,25 +652,6 @@ func (s *Store) ReserveMergeTurn(ctx context.Context, id, sessionID string) (boo
return reserved, err
}
func (s *Store) MergeClaims(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id FROM tasks WHERE status = ? AND merge_after_turn = 1`, string(api.TaskStatusActive))
if err != nil {
return nil, err
}
defer rows.Close()
ids := []string{}
for rows.Next() {
id := ""
err = rows.Scan(&id)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
type MergeAfterReview struct {
By string
ReviewID string
@@ -1335,21 +1184,50 @@ func (s *Store) Broadcast(ev api.Event) {
}
func (s *Store) CreateSession(ctx context.Context, sess api.AgentSession, projectID string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
return insertSession(ctx, tx, emit, sess, projectID)
})
}
func insertSession(ctx context.Context, tx *sql.Tx, emit emitFunc, sess api.AgentSession, projectID string) error {
forkedFrom := ""
forkedAt := ""
if sess.ForkedFrom != nil && sess.ForkedAt != nil {
forkedFrom = *sess.ForkedFrom
forkedAt = *sess.ForkedAt
}
_, err := tx.ExecContext(ctx, `
INSERT INTO agent_sessions (id, task_id, owner, vendor, model, vendor_session_id, state, role, member, created_by, created_at, forked_from, forked_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, sess.Id, nullable(sess.TaskId), sess.Owner, string(sess.Vendor), sess.Model, sess.VendorSessionId, string(sess.State), string(sess.Role), sess.Member, sess.CreatedBy, formatTime(sess.CreatedAt), forkedFrom, forkedAt)
if err != nil {
return err
}
return emit(sessionEvent("session.created", projectID, sess.TaskId, sess.Owner, sess.Id, sess.CreatedBy, ToPayload(sess)))
}
func (s *Store) ReplaceSession(ctx context.Context, oldID string, sess api.AgentSession, projectID, actor string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO agent_sessions (id, task_id, owner, vendor, model, vendor_session_id, state, role, member, created_by, created_at, forked_from, forked_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, sess.Id, nullable(sess.TaskId), sess.Owner, string(sess.Vendor), sess.Model, sess.VendorSessionId, string(sess.State), string(sess.Role), sess.Member, sess.CreatedBy, formatTime(sess.CreatedAt), forkedFrom, forkedAt)
status, discard := "", ""
err := tx.QueryRowContext(ctx, `SELECT status, discard_state FROM tasks WHERE id = ?`, sess.TaskId).Scan(&status, &discard)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
return emit(sessionEvent("session.created", projectID, sess.TaskId, sess.Owner, sess.Id, sess.CreatedBy, ToPayload(sess)))
if status != string(api.TaskStatusActive) {
return fmt.Errorf("%w: task %s is %s", ErrNotFound, sess.TaskId, status)
}
if discard != "" {
return fmt.Errorf("%w: task %s is being discarded", ErrStateChanged, sess.TaskId)
}
running := api.AgentSessionStateRunning
err = transitionSessionTx(ctx, tx, emit, oldID, &running, api.AgentSessionStateCompleted, actor, nil)
if err != nil {
return err
}
return insertSession(ctx, tx, emit, sess, projectID)
})
}