Add merge_to_main tool to land work mid-task and keep going

This commit is contained in:
Ian Gulliver
2026-10-18 20:30:51 -07:00
parent 4c2dfb3409
commit 7138877156
5 changed files with 192 additions and 2 deletions
+2
View File
@@ -85,6 +85,8 @@ git's network commands (`git fetch` and `git push`, through `runNetwork` in `int
One merge runs per project at a time. The Manager keeps, per project, a lock and the task holding that project's merge in memory (`projectMerge` in `internal/agents/mergein.go`, in `Manager.merges`), from when `merging_by` is set until the merge closes the task, fails, or is abandoned as above, or the task is discarded; on startup it is rebuilt from the active tasks with `merging_by` set. The fetch runs after the lock is released but while the task holds the merge, so a stalled remote delays merges in that project alone, and Merge requests in the same project meanwhile see its holder. Merge on another task of that project while one is held doesn't fetch or start a turn: it records the request in `tasks.merge_queued_by` and `merge_queued_at` (`merge_queued_at` on the Task), emits `task.merge_queued` (a transcript note), and returns the task with 202. A second Merge on a held or merging task is a 409. Whenever a project's merge is released, any task turn in it ends, and on startup, byre starts the oldest held merge in the project whose task session is not `running`: it clears the request (emitting `task.merge_unqueued`, not drawn) and goes through the same fetch and turn as a direct Merge by the user who asked, so it merges in what the earlier merge pushed. If that start fails before the turn begins (the user has no git identity, or the session isn't idle), byre records the error in the task's transcript and tries the next held merge; a fetch failure after that ends the turn like a direct Merge's, as a transcript note. The Merge button reads **Merge held**, disabled, while the task has `merge_queued_at`; discarding the task drops the request. `sqlite3 byre.db "SELECT id, merge_queued_by, merge_queued_at FROM tasks WHERE merge_queued_by != ''"` lists held merges. One merge runs per project at a time. The Manager keeps, per project, a lock and the task holding that project's merge in memory (`projectMerge` in `internal/agents/mergein.go`, in `Manager.merges`), from when `merging_by` is set until the merge closes the task, fails, or is abandoned as above, or the task is discarded; on startup it is rebuilt from the active tasks with `merging_by` set. The fetch runs after the lock is released but while the task holds the merge, so a stalled remote delays merges in that project alone, and Merge requests in the same project meanwhile see its holder. Merge on another task of that project while one is held doesn't fetch or start a turn: it records the request in `tasks.merge_queued_by` and `merge_queued_at` (`merge_queued_at` on the Task), emits `task.merge_queued` (a transcript note), and returns the task with 202. A second Merge on a held or merging task is a 409. Whenever a project's merge is released, any task turn in it ends, and on startup, byre starts the oldest held merge in the project whose task session is not `running`: it clears the request (emitting `task.merge_unqueued`, not drawn) and goes through the same fetch and turn as a direct Merge by the user who asked, so it merges in what the earlier merge pushed. If that start fails before the turn begins (the user has no git identity, or the session isn't idle), byre records the error in the task's transcript and tries the next held merge; a fetch failure after that ends the turn like a direct Merge's, as a transcript note. The Merge button reads **Merge held**, disabled, while the task has `merge_queued_at`; discarding the task drops the request. `sqlite3 byre.db "SELECT id, merge_queued_by, merge_queued_at FROM tasks WHERE merge_queued_by != ''"` lists held merges.
A task agent lands its work so far without closing the task through the `merge_to_main` MCP tool (`internal/agents/mergemain.go`), with a `title` checked like `set_merge_title`'s. It runs inside the tool call, holding the task's lock (`lockTask`) and the project's merge (`projectMerge.holder`, in memory only), and is refused (409) while another task holds the project's merge, while this task is merging, held, merging into its parent or being discarded, and from a Merge or Sync turn that owns the worktree's merge. It fetches and fast-forwards the checkout as Merge does, commits the worktree (`git.CommitAll`, the title as message, which also concludes a `git merge --no-commit` the agent left, even one whose resolution changes no files), squashes it onto the default branch titled `title` with the same co-author trailers, note and atomic push as Merge, as the session's creator, then advances the checkout's notes and fast-forwards it. Finally it merges the squash commit back into the task branch (`git.Merge`, `git merge --no-ff`), which can't conflict since the squash already combined both sides, so the branch's merge base moves to the squash commit and later changes, snapshots and the task's final Merge carry only what came after. It records a `task.merged_to_main` event with the commit, `pushed` and the title, drawn as a note; the task's own `merge_title` is unchanged. A conflict with the default branch is returned to the agent with the files and the `git merge --no-ff --no-commit` to run before calling again; so is a push the remote refused as moved. Other push errors are returned as is, without Merge's landed check. When the squash changes nothing, nothing is pushed, and byre merges the default branch into the task branch instead, which can't conflict either since the default branch already has all the branch's work; so a call retried after a push that landed but reported failure, or after byre restarted between the push and the merge-back, still moves the branch's merge base past the landed work.
`POST /api/tasks/{id}/merge` with `after_review: true` (the dialog's **After review**) starts nothing (`MergeAfterReview` in `internal/agents/afterreview.go`). It checks what the chosen target's Merge would (git identity and title for `main`, a parent for `parent`, no subtasks, not already merging, held or merging into the parent), stores the title like Merge does, and records the requesting user, the running review of the latest snapshot and the target in `tasks.merge_after_review_by`, `merge_after_review_id` and `merge_after_review_target` (`merge_after_review` on the Task; a `task.merge_after_review` event, drawn as a note). It is a 409 when that review isn't running. Asking again replaces the request. Whenever a review finishes (submitted, failed, interrupted, or failed by a restart or the task closing), `reviewFinished` takes the request if it names that review (emitting `task.merge_after_review_ended`, not drawn) and makes the merge as that user, through the same `Merge` or `MergeIntoParent` as a direct request, when the review is `done` with no findings, its snapshot is still the latest, the task is active and not being discarded, and no turn is running. Otherwise, or when that merge is refused, it drops the request with a `task.merge_after_review_dropped` event carrying the reason, drawn as a note. On startup, `recoverMergeAfterReviews` runs the same check for every request whose review is no longer `running`, so one left by a restart between the review finishing and its request being taken still merges or is dropped. A direct Merge (either target) clears the request; so do Discard and closing the task. `DELETE /api/tasks/{id}/merge-after-review` withdraws it (404 when none is waiting), emitting `task.merge_after_review_cancelled`, drawn as a note naming who cancelled. The transcript's `task.merge_after_review` note carries a **Cancel** button that calls it; only the last such note in the transcript shows the button, and only while the task has `merge_after_review` (`NoteView` and `lastMergeAfterReview` in `mac/Sources/Byre/Chat.swift` and `TranscriptBuilder.swift`). The Merge button reads **Merge after review**, still enabled so the dialog can merge now, and the task state label is **Merge after review**. `sqlite3 byre.db "SELECT id, merge_after_review_by, merge_after_review_id FROM tasks WHERE merge_after_review_by != ''"` lists them. `POST /api/tasks/{id}/merge` with `after_review: true` (the dialog's **After review**) starts nothing (`MergeAfterReview` in `internal/agents/afterreview.go`). It checks what the chosen target's Merge would (git identity and title for `main`, a parent for `parent`, no subtasks, not already merging, held or merging into the parent), stores the title like Merge does, and records the requesting user, the running review of the latest snapshot and the target in `tasks.merge_after_review_by`, `merge_after_review_id` and `merge_after_review_target` (`merge_after_review` on the Task; a `task.merge_after_review` event, drawn as a note). It is a 409 when that review isn't running. Asking again replaces the request. Whenever a review finishes (submitted, failed, interrupted, or failed by a restart or the task closing), `reviewFinished` takes the request if it names that review (emitting `task.merge_after_review_ended`, not drawn) and makes the merge as that user, through the same `Merge` or `MergeIntoParent` as a direct request, when the review is `done` with no findings, its snapshot is still the latest, the task is active and not being discarded, and no turn is running. Otherwise, or when that merge is refused, it drops the request with a `task.merge_after_review_dropped` event carrying the reason, drawn as a note. On startup, `recoverMergeAfterReviews` runs the same check for every request whose review is no longer `running`, so one left by a restart between the review finishing and its request being taken still merges or is dropped. A direct Merge (either target) clears the request; so do Discard and closing the task. `DELETE /api/tasks/{id}/merge-after-review` withdraws it (404 when none is waiting), emitting `task.merge_after_review_cancelled`, drawn as a note naming who cancelled. The transcript's `task.merge_after_review` note carries a **Cancel** button that calls it; only the last such note in the transcript shows the button, and only while the task has `merge_after_review` (`NoteView` and `lastMergeAfterReview` in `mac/Sources/Byre/Chat.swift` and `TranscriptBuilder.swift`). The Merge button reads **Merge after review**, still enabled so the dialog can merge now, and the task state label is **Merge after review**. `sqlite3 byre.db "SELECT id, merge_after_review_by, merge_after_review_id FROM tasks WHERE merge_after_review_by != ''"` lists them.
## Subtasks ## Subtasks
+1 -1
View File
@@ -738,7 +738,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
if err != nil { if err != nil {
return nil, err return nil, err
} }
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l), m.terminalReadTool(l), m.terminalTypeTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...) tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.mergeToMainTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l), m.terminalReadTool(l), m.terminalTypeTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...)
lead := true lead := true
if sess.Role == api.Implementer { if sess.Role == api.Implementer {
g += longTitleGuidance(task) g += longTitleGuidance(task)
+170
View File
@@ -0,0 +1,170 @@
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/git"
"g.fc.run/ai/byre/internal/mcp"
)
type mergeToMainArgs struct {
Title string `json:"title"`
}
func (m *Manager) mergeToMainTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "merge_to_main",
Description: "Land this task's work so far on the default branch now and keep working: byre commits the worktree, squashes the branch onto the default branch as one commit titled title, pushes it, " +
"and merges that commit back into this branch, so the task stays open and its later Merge carries only what comes after. " +
"Refused when the branch conflicts with the default branch: merge the default branch in, resolve the conflicts, and call it again.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"title": {"type": "string", "description": "The commit's title: one line in the imperative mood, at most 72 characters, describing the work landing now."}
},
"required": ["title"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.mergeToMain(ctx, l, raw)
},
}
}
func (m *Manager) mergeToMain(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
args := mergeToMainArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed merge_to_main call: %w", err)
}
title, err := cleanMergeTitle(args.Title)
if err != nil {
return "", err
}
if m.ownsTask(l.id) {
return "", fmt.Errorf("%w: this turn is merging into the worktree; end it first", ErrConflict)
}
sess, err := m.store.GetSession(ctx, l.id)
if err != nil {
return "", err
}
user, err := m.store.GetUser(ctx, sess.CreatedBy)
if err != nil {
return "", err
}
if user.GitName == "" || user.GitEmail == "" {
return "", ErrNoGitIdentity
}
unlock := m.lockTask(l.taskID)
defer unlock()
task, err := m.store.GetTask(ctx, l.taskID)
if err != nil {
return "", err
}
if task.Status != api.TaskStatusActive || task.Merging || task.MergeQueuedAt != nil || task.MergingIntoParent || task.Discarding {
return "", fmt.Errorf("%w: task %s is %s", ErrConflict, task.Id, task.State)
}
project, err := m.store.GetProject(ctx, task.ProjectId)
if err != nil {
return "", err
}
pm := m.projectMerge(project.Id)
pm.mu.Lock()
holder := pm.holder
if holder == "" {
pm.holder = task.Id
}
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)
commit, err := m.pushToMain(ctx, l, task, project, user, title)
if err != nil {
return "", err
}
if commit == "" {
return fmt.Sprintf("Nothing to push: %s already has all of this branch's work, so byre merged %s into this branch. Keep working; the task stays open.", project.DefaultBranch, project.DefaultBranch), nil
}
pushed := git.Remote + "/" + project.DefaultBranch
err = m.emit(ctx, l, agentActor, "task.merged_to_main", map[string]any{"id": task.Id, "commit": commit, "pushed": pushed, "merge_title": title})
if err != nil {
return "", err
}
return fmt.Sprintf("Pushed %s %q to %s and merged it back into this branch. Keep working; the task stays open.", commit, title, pushed), nil
}
func (m *Manager) pushToMain(ctx context.Context, l *live, task api.Task, project api.Project, user api.User, title string) (string, error) {
err := git.FetchFastForward(ctx, project.RepoPath, project.DefaultBranch)
if err != nil {
return "", err
}
_, err = git.CommitAll(l.dir, title, l.gitName, l.gitEmail)
if err != nil {
return "", err
}
coAuthors, err := m.CoAuthors(ctx, task.Id, user.Login)
if err != nil {
return "", err
}
env := []string{
"GIT_AUTHOR_NAME=" + user.GitName,
"GIT_AUTHOR_EMAIL=" + user.GitEmail,
"GIT_COMMITTER_NAME=" + user.GitName,
"GIT_COMMITTER_EMAIL=" + user.GitEmail,
}
commit, err := git.SquashCommit(project.RepoPath, l.dir, project.DefaultBranch, squashMessage(title, coAuthors), env)
conflict := &git.ConflictError{}
if errors.As(err, &conflict) {
return "", fmt.Errorf("%w; run `git merge --no-ff --no-commit %s`, resolve them, and call merge_to_main again", err, project.DefaultBranch)
}
if err != nil {
return "", err
}
if commit == "" {
err = git.Merge(l.dir, project.DefaultBranch, "Merge "+project.DefaultBranch, l.gitName, l.gitEmail)
return "", err
}
task.MergeTitle = title
note, err := m.mergeNote(ctx, task, user.Login)
if err != nil {
return "", err
}
notes, err := git.NoteCommit(project.RepoPath, commit, note, env)
if err != nil {
return "", err
}
err = git.Push(ctx, project.RepoPath, commit, project.DefaultBranch, notes)
if errors.Is(err, git.ErrRemoteMoved) {
return "", fmt.Errorf("%w; call merge_to_main again", err)
}
if err != nil {
return "", err
}
pushed := git.Remote + "/" + project.DefaultBranch
err = git.AdvanceNotes(project.RepoPath, notes)
if err != nil {
return "", fmt.Errorf("pushed %s to %s, but updating %s in the project checkout failed: %w", commit, pushed, git.NotesRef, err)
}
err = git.FastForward(project.RepoPath, commit)
if err != nil {
return "", fmt.Errorf("pushed %s to %s, but fast-forwarding the project checkout failed: %w", commit, pushed, err)
}
err = git.Merge(l.dir, commit, fmt.Sprintf("Merge %s from %s", commit[:7], project.DefaultBranch), l.gitName, l.gitEmail)
if err != nil {
return "", fmt.Errorf("pushed %s to %s, but merging it back into this branch failed: %w", commit, pushed, err)
}
return commit, nil
}
+16 -1
View File
@@ -329,7 +329,11 @@ func CommitAll(dir, message, name, email string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
if status == "" { merging, err := MergeInProgress(dir)
if err != nil {
return "", err
}
if status == "" && !merging {
return "", nil return "", nil
} }
@@ -350,6 +354,17 @@ func CommitAll(dir, message, name, email string) (string, error) {
return Head(dir) return Head(dir)
} }
func Merge(dir, rev, message, name, email string) error {
env := []string{
"GIT_AUTHOR_NAME=" + name,
"GIT_AUTHOR_EMAIL=" + email,
"GIT_COMMITTER_NAME=" + name,
"GIT_COMMITTER_EMAIL=" + email,
}
_, err := runEnv(dir, env, "merge", "--no-ff", "--quiet", "--message", message, rev)
return err
}
func run(dir string, args ...string) (string, error) { func run(dir string, args ...string) (string, error) {
return runEnv(dir, nil, args...) return runEnv(dir, nil, args...)
} }
+3
View File
@@ -733,6 +733,9 @@ final class TranscriptBuilder {
let pushed = str(p, "pushed") ?? "" let pushed = str(p, "pushed") ?? ""
return note([.text("\(ev.actor) merged the task\(commit.isEmpty ? "" : " as \(commit.prefix(7))")\(pushed.isEmpty ? "" : " and pushed it to \(pushed)")")]) return note([.text("\(ev.actor) merged the task\(commit.isEmpty ? "" : " as \(commit.prefix(7))")\(pushed.isEmpty ? "" : " and pushed it to \(pushed)")")])
case "task.merged_to_main":
return note([.text("\(ev.actor) merged the work so far as \(jsString(p["commit"]).prefix(7)), \"\(jsString(p["merge_title"]))\", pushed it to \(jsString(p["pushed"])) and kept the task open")])
case "task.summary": case "task.summary":
return note([.text("\(ev.actor) set the task summary")]) return note([.text("\(ev.actor) set the task summary")])