diff --git a/docs/operations.md b/docs/operations.md index 32b113d..01f47e3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -96,7 +96,7 @@ One merge runs per project at a time. The Manager keeps, per project, a lock and 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 `keep_open: true` (the ready card's **Merge & continue**) has the task's agent do it (`Manager.MergeToMain` in `internal/agents/mergemain.go`): it sends the team's first agent a byre message (`landPrompt`) to merge the default branch in with `git merge --no-ff --no-commit`, resolve and check as a Sync does, and then call `merge_to_main` with the dialog's title, whose commit concludes the merge (the prompt avoids `mergeInMarker`, so the turn doesn't own the worktree's merge the way a Sync turn does, which would refuse `merge_to_main`; the hold below keeps teammates out instead, and `turnEnded` aborts the merge of a landing turn that ended unfinished, as it does for a Sync turn); or, when the resolution took substantive changes, to stop and describe them so the review sees them before anything lands. For that turn the task's other agents are held: `Manager.landing` (in memory) maps the task to the landing session and the requesting user, whom that session's `merge_to_main` uses in place of the session's creator for authorship, signing and co-authors, `startRunning` (which `turn` and `Compact` move a session to `running` through) refuses (409) to start any other session of the task, so answers and queued messages stay undelivered, and `Message` queues their messages as task inputs. The hold ends when the landing session's turn ends however it ends (`endLanding`, deferred in `turnEnded`), or when sending the prompt fails, and then byre delivers the queued messages and answers of each idle implementer other than the landing session (whose own turn end handles it) and other than one the human interrupted (`releaseTeam`, through `Deliver`). A restart drops the hold along with the turn. The request takes the session's delivery lock, records the hold, and only then checks that none of the task's implementer sessions is running, so a teammate starting at the same moment either sees the hold or is seen running (`startRunning` checks the hold again after moving the session to `running` and moves it back if held). It is a 400 with `target: parent` or `after_review`, and a 409 while any of the task's agents is running, while the task is merging, held, merging into its parent or being discarded, when it has no agent, or when it is already merging and continuing. Just before sending the prompt it clears a pending merge after review, as a direct Merge does; once the prompt is sent it clears a standing ready_for_merge or suggest_discard (`task.ready_cleared`). +`POST /api/tasks/{id}/merge` with `keep_open: true` (the ready card's **Merge & continue**) has the task's agent do it (`Manager.MergeToMain` in `internal/agents/mergemain.go`): it sends the team's first agent a byre message (`landPrompt`) to merge the default branch in with `git merge --no-ff --no-commit`, resolve and check as a Sync does, and then call `merge_to_main` with the dialog's title, whose commit concludes the merge (the prompt avoids `mergeInMarker`, so the turn doesn't own the worktree's merge the way a Sync turn does, which would refuse `merge_to_main`; the hold below keeps teammates out instead, and `turnEnded` aborts the merge of a landing turn that ended unfinished, as it does for a Sync turn); or, when the resolution took substantive changes, to stop and describe them so the review sees them before anything lands. For that turn the task's other agents are held: `Manager.landing` (in memory) maps the task to the landing session and the requesting user, whom that session's `merge_to_main` uses in place of the session's creator for authorship, signing and co-authors, `startRunning` (which `turn` and `Compact` move a session to `running` through) refuses (409) to start any other session of the task, so answers and queued messages stay undelivered, and `Message` queues their messages as task inputs. The hold ends when the landing session's turn ends however it ends (`endLanding`, deferred in `turnEnded`), or when sending the prompt fails, and then byre delivers the queued messages and answers of each idle implementer other than the landing session (whose own turn end handles it) and other than one the human interrupted (`releaseTeam`, through `Deliver`). A restart drops the hold along with the turn. The request takes the session's delivery lock, records the hold, and only then checks that none of the task's implementer sessions is running, so a teammate starting at the same moment either sees the hold or is seen running (`startRunning` checks the hold again after moving the session to `running` and moves it back if held). It is a 400 with `target: parent` or `after_review`, and a 409 while any of the task's agents is running, while the task is merging, held, merging into its parent or being discarded, when it has no agent, or when it is already merging and continuing. Just before sending the prompt it clears a pending merge after review, as a direct Merge does; once the prompt is sent it clears a standing ready_for_merge or suggest_discard (`task.ready_cleared` with `reason: merge`, which the transcript draws no note for and the lead gets no signal for, since the landing prompt already shows the merge). `POST /api/tasks/{id}/merge` with `after_review: true` (the dialog's **Merge & complete 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. diff --git a/internal/agents/lead.go b/internal/agents/lead.go index f6b43f9..5360e8c 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -352,7 +352,11 @@ func (m *Manager) Observe(events []api.Event) { go m.promptReady(*ev.TaskId, nil) } switch ev.Kind { - case "inbox.created", "inbox.updated", "task.created", "task.merge_queued", "task.merge_after_review", "task.merge_after_review_cancelled", "task.merge_after_review_dropped", "task.merge_started", "task.merged", "task.discarded", "turn.ended", "task.workflow_stage", "task.workflow_finished", "review.started", "review.finished", "task.state", "task.ready", "task.discard_suggested", "task.ready_cleared": + case "inbox.created", "inbox.updated", "task.created", "task.merge_queued", "task.merge_after_review", "task.merge_after_review_cancelled", "task.merge_after_review_dropped", "task.merge_started", "task.merged", "task.discarded", "turn.ended", "task.workflow_stage", "task.workflow_finished", "review.started", "review.finished", "task.state", "task.ready", "task.discard_suggested": + case "task.ready_cleared": + if ev.Payload["reason"] == "merge" { + continue + } case "user.message": if ev.TaskId == nil || !humanActor(ev.Actor) { continue diff --git a/internal/agents/mergemain.go b/internal/agents/mergemain.go index c81af08..ff7cfc4 100644 --- a/internal/agents/mergemain.go +++ b/internal/agents/mergemain.go @@ -230,7 +230,7 @@ func (m *Manager) MergeToMain(ctx context.Context, task api.Task, project api.Pr return api.Task{}, err } - err = m.store.ClearReady(ctx, task.Id, user.Login) + err = m.store.ClearReady(ctx, task.Id, user.Login, "merge") if err != nil && !errors.Is(err, store.ErrNotFound) { return api.Task{}, err } diff --git a/internal/agents/ready.go b/internal/agents/ready.go index c4899c7..4ef06e8 100644 --- a/internal/agents/ready.go +++ b/internal/agents/ready.go @@ -118,7 +118,7 @@ func (m *Manager) cancelReadyTool(l *live) mcp.Tool { "The human then no longer sees the task as ready to merge or as suggested for discard; call ready_for_merge or suggest_discard again once it holds.", InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`), Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { - err := m.store.ClearReady(ctx, l.taskID, agentActor) + err := m.store.ClearReady(ctx, l.taskID, agentActor, "") if errors.Is(err, store.ErrNotFound) { return "", fmt.Errorf("%w: this task has no standing ready_for_merge or suggest_discard", ErrConflict) } diff --git a/internal/server/server.go b/internal/server/server.go index 3c24c6f..bbf5131 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -601,7 +601,7 @@ func (s *Server) CancelMergeAfterReview(w http.ResponseWriter, r *http.Request, } func (s *Server) DismissReady(w http.ResponseWriter, r *http.Request, taskID string) { - err := s.store.ClearReady(r.Context(), taskID, actor(r)) + err := s.store.ClearReady(r.Context(), taskID, actor(r), "") if err != nil { writeStoreError(w, err) return diff --git a/internal/store/store.go b/internal/store/store.go index efea8ff..ef83fb9 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -789,7 +789,7 @@ func (s *Store) SuggestDiscard(ctx context.Context, id, reason, actor string) er return s.setReady(ctx, id, actor, "discard", "task.discard_suggested", map[string]any{"id": id, "reason": reason}) } -func (s *Store) ClearReady(ctx context.Context, id, actor string) error { +func (s *Store) ClearReady(ctx context.Context, id, actor, reason string) error { return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { projectID := "" err := tx.QueryRowContext(ctx, `UPDATE tasks SET ready = '' WHERE id = ? AND status = ? AND ready != '' RETURNING project_id`, id, string(api.TaskStatusActive)).Scan(&projectID) @@ -799,7 +799,11 @@ func (s *Store) ClearReady(ctx context.Context, id, actor string) error { if err != nil { return err } - return emit(api.Event{Kind: "task.ready_cleared", ProjectId: &projectID, TaskId: &id, Actor: actor, Payload: map[string]any{"id": id}}) + payload := map[string]any{"id": id} + if reason != "" { + payload["reason"] = reason + } + return emit(api.Event{Kind: "task.ready_cleared", ProjectId: &projectID, TaskId: &id, Actor: actor, Payload: payload}) }) } diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index 8715de3..15a0838 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -517,6 +517,12 @@ final class TranscriptBuilder { continue } } + if ev.kind == "task.ready_cleared" && str(p, "reason") == "merge" { + if sink == nil && own { + lastReady = nil + } + continue + } if ev.kind == "inbox.updated" { let item = try! decodePayload(ev.payload, as: InboxItem.self) ctx.questions[item.id]?.question = item