held merge should sync

This commit is contained in:
Ian Gulliver
2026-10-18 23:28:17 -07:00
parent 9625be25fd
commit 5aa8a8fbe4
2 changed files with 34 additions and 6 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ Each squash commit gets a git note under `refs/notes/byre`, the durable record o
git's network commands (`git fetch` and `git push`, through `runNetwork` in `internal/git/git.go`) are bounded by `networkTimeout` and by the caller's context, and run non-interactively: `GIT_TERMINAL_PROMPT=0`, and `GIT_SSH_COMMAND` set to `ssh` with `BatchMode=yes` and a connect timeout (`networkEnv`), which replaces any `core.sshCommand` the checkout configures. A remote needing a password or an unknown host key fails at once instead of waiting for input. Every git command runs in its own process group, and cancelling one kills the whole group, so an `ssh` or remote helper it started can't outlive the deadline and finish a fetch or push after byre has moved on. A timed-out fetch fails the Merge like any other fetch error; a timed-out push is ambiguous and goes through the landed check above. git's network commands (`git fetch` and `git push`, through `runNetwork` in `internal/git/git.go`) are bounded by `networkTimeout` and by the caller's context, and run non-interactively: `GIT_TERMINAL_PROMPT=0`, and `GIT_SSH_COMMAND` set to `ssh` with `BatchMode=yes` and a connect timeout (`networkEnv`), which replaces any `core.sshCommand` the checkout configures. A remote needing a password or an unknown host key fails at once instead of waiting for input. Every git command runs in its own process group, and cancelling one kills the whole group, so an `ssh` or remote helper it started can't outlive the deadline and finish a fetch or push after byre has moved on. A timed-out fetch fails the Merge like any other fetch error; a timed-out push is ambiguous and goes through the landed check above.
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. It then gives the task a head start (`headStartMerge`): when the task's agent is idle (or it has none), it sends the same merge-in of the default branch as the merge-in action, from the project checkout's local default branch without a fetch, so the later Merge turn has less left to resolve; a running agent skips it. It moves the session to `running` under the project's merge lock, as a direct Merge does, so a held merge can't start in between and find the session taken. The head-start prompt tells the agent to call `stop_merge` if resolving the conflicts took substantive changes; on a task with no `merging_by` but a held merge, `stop_merge` clears the held request (`TakeQueuedMerge`) instead, so nothing is pushed before the review. A held merge whose head-start turn is still running waits for that turn to end, like any running task. 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. 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.
+33 -5
View File
@@ -39,6 +39,25 @@ func (m *Manager) MergeIn(ctx context.Context, task api.Task, user api.User, tar
return m.sendMergeIn(ctx, task, user, prompt) return m.sendMergeIn(ctx, task, user, prompt)
} }
func (m *Manager) headStartMerge(ctx context.Context, task api.Task, user api.User, target string) {
sess, err := m.mergeSession(ctx, task, user)
if errors.Is(err, ErrConflict) {
slog.Info("skip held merge head start", "task", task.Id, "err", err)
return
}
if err != nil {
slog.Error("held merge head start", "task", task.Id, "err", err)
return
}
prompt := mergeInPrompt(target) + fmt.Sprintf(" This task's Merge is held while another task merges into %s; this turn gets a head start, and byre merges %s in again when the held merge starts. If you stop for substantive changes, call stop_merge first, so byre drops the held merge and nothing is pushed before the review.", target, target)
go func() {
_, err := m.launch(context.Background(), sess, task, "byre", prompt, nil)
if err != nil {
slog.Error("start held merge head start", "task", task.Id, "err", err)
}
}()
}
func (m *Manager) sendMergeIn(ctx context.Context, task api.Task, user api.User, prompt string) (api.AgentSession, error) { func (m *Manager) sendMergeIn(ctx context.Context, task api.Task, user api.User, prompt string) (api.AgentSession, error) {
sess, found, err := m.liveSession(ctx, task.Id) sess, found, err := m.liveSession(ctx, task.Id)
if err != nil { if err != nil {
@@ -98,6 +117,7 @@ func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project,
if err != nil { if err != nil {
return api.Task{}, err return api.Task{}, err
} }
m.headStartMerge(ctx, task, user, project.DefaultBranch)
} }
m.clearMergeAfterReview(ctx, task.Id) m.clearMergeAfterReview(ctx, task.Id)
return m.store.GetTask(ctx, task.Id) return m.store.GetTask(ctx, task.Id)
@@ -625,8 +645,8 @@ type stopMergeArgs struct {
func (m *Manager) stopMergeTool(l *live) mcp.Tool { func (m *Manager) stopMergeTool(l *live) mcp.Tool {
return mcp.Tool{ return mcp.Tool{
Name: "stop_merge", Name: "stop_merge",
Description: "Stop the merge byre is to make when this turn ends, so the task stays active and its snapshot is reviewed before anything is pushed. " + Description: "Stop the merge byre is to make when this turn ends, or drop the held merge waiting for another task's, so the task stays active and its snapshot is reviewed before anything is pushed. " +
"Call it during a Merge turn when resolving conflicts took substantive changes, then end your turn describing them. The human merges again after the review.", "Call it during a Merge turn, or a held merge's head-start merge-in, when resolving conflicts took substantive changes, then end your turn describing them. The human merges again after the review.",
InputSchema: json.RawMessage(`{ InputSchema: json.RawMessage(`{
"type": "object", "type": "object",
"properties": { "properties": {
@@ -654,10 +674,18 @@ func (m *Manager) stopMerge(ctx context.Context, l *live, raw json.RawMessage) (
if !m.leadsTask(ctx, l.taskID, l.id) { 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) return "", fmt.Errorf("%w: only the team's first agent runs the merge, so only it can stop it", ErrConflict)
} }
if m.takeMerge(l.taskID) == "" { switch {
return "No merge is pending for this task; just end your turn.", nil case m.takeMerge(l.taskID) != "":
go m.releaseMerge(l.projectID, l.taskID)
default:
queuedBy, err := m.store.TakeQueuedMerge(ctx, l.taskID)
if err != nil {
return "", err
}
if queuedBy == "" {
return "No merge is pending for this task; just end your turn.", nil
}
} }
go m.releaseMerge(l.projectID, l.taskID)
err = m.emit(ctx, l, agentActor, "task.merge_stopped", map[string]any{"id": l.taskID, "reason": reason}) err = m.emit(ctx, l, agentActor, "task.merge_stopped", map[string]any{"id": l.taskID, "reason": reason})
if err != nil { if err != nil {