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.
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.
## Subtasks