Run the merge queue on a state-driven coordinator
This commit is contained in:
+22
-18
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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. "+
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user