diff --git a/api/openapi.yaml b/api/openapi.yaml index b4a95fd..4c9693e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -444,6 +444,11 @@ paths: (merge_after_review is set) and made, as this caller and with this target, when the review of the task's latest snapshot finishes with no findings, that snapshot is still the latest and no turn is running; otherwise it is dropped with a transcript note. Refused (409) unless that review is running. + With keep_open (target main only, not with after_review), the task's agent gets a turn that merges the + default branch in, resolving any conflicts, and then lands the work so far with merge_to_main titled + merge_title (the task's own merge_title is unchanged); the task stays open. The task's other agents are held + until that turn ends: their messages queue and nothing starts their turns. It clears a standing + ready_for_merge. Refused (409) while any of the task's agents is mid-turn, or while it is merging. requestBody: required: true content: @@ -459,9 +464,10 @@ paths: x-enum-varnames: [MergeTargetMain, MergeTargetParent] description: "main (the default): squash onto the project's default branch. parent: merge into the parent task's branch; only for a subtask." after_review: { type: boolean, description: "Merge once the running review of the latest snapshot finishes with no findings, instead of now." } + keep_open: { type: boolean, description: "Land the work so far and keep the task open, instead of closing it." } responses: "202": - description: The merge was started, held, or set to start after the review. + description: The merge was started, held, set to start after the review, or (keep_open) its turn started. content: application/json: schema: { $ref: "#/components/schemas/Task" } diff --git a/docs/operations.md b/docs/operations.md index d8146c4..94416f7 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -37,9 +37,9 @@ A task ends `merged` or `discarded`; either way its sessions are stopped, its sn A task's summary is what its agent writes, before the task closes, about the whole task: what it set out to do, what was built or tried, the key decisions and why, and what was left out. The agent sets it with the `set_task_summary` MCP tool (`internal/agents/summary.go`), any time while the task is active; each call replaces `tasks.summary` and records a `task.summary` event carrying it, which the transcript shows as a note. The Merge turn's prompt and the discard turn's prompt (`summaryRequest`) both ask for it. Turn summaries don't stand in for it: each covers only its own turn, and a merged task's last one is always the Merge turn's. Tasks closed before the column existed have none. `sqlite3 byre.db "SELECT id, status, summary FROM tasks WHERE status != 'active' ORDER BY closed_at DESC LIMIT 5"` shows recent ones. -An implementer says its task is ready to merge with the `ready_for_merge` MCP tool (`internal/agents/ready.go`), whose `overview` covers the whole task for a human switching between tasks: a summary, tradeoffs and compromises, what was not done, and future TODOs. Its description tells the agent to call it only once the review of its most recent changes has come back, never in the same turn as a change or while that review is pending. Each call records a `task.ready` event carrying it, which the lead gets as a signal and the transcript draws as a bubble (`ReadyBubble` in `mac/Sources/Byre/Chat.swift`) headed "ready for merge". The latest one, while the task is active and no question, `task.ready_cleared` or snapshot other than an `answer`, `fork` or `ask_human` one (the never-reviewed kinds) has come since it (a question, a withdrawal or dismissal, or newer work the overview doesn't cover), is drawn like an open question (the warn tint and border) and carries small Dismiss, Merge and Discard buttons that act like the header's, confirmation dialogs included; earlier ones are drawn like an answered question (the faint warn tint), without buttons. Recording it changes nothing else. byre asks for it (`readyPrompt`, sent as a byre message) when a task's state goes to `idle`, `ready_to_merge` or `discard_suggested` (`readyState`) or its workflow finishes (`Manager.Observe` calls `promptReady`) and the latest reviewable snapshot's review is `done` or `skipped`. It checks and sends under the session's delivery lock (the one human messages and answers take), and only to an agent that isn't running, so the prompt never queues behind a turn that could add unreviewed work. `tasks.ready_prompt_seq` holds the task's last event seq when the prompt's turn started; byre asks again only once a reviewable snapshot or a message from the human (a chat message or a question's answer, not one from byre, the agent or the lead) has come after it, so the agent's own turns that change no files don't ask again, and a human unblocking an agent that said it was waiting does. It doesn't ask while the task has an open question. It doesn't ask for a workflow stage task until its run has a `task.workflow_finished` event (by then the run's other stage tasks are being discarded, leaving the final one for the human), a task in any other state (an open question, a held merge or a merge after review among them), or a review that failed, and it skips the `reviewing` → `idle` change of a review with findings, since those go to the agent; the turn that answers them asks once it ends without new work. The prompt tells the agent not to call the tool, and to say what's left, when the task isn't ready. When that review is `skipped` (the snapshot has no diff from the default branch), byre sends `readyPromptUnchanged` instead, which says there is nothing to merge and offers only `suggest_discard` and `ask_human`. +An implementer says its task is ready to merge with the `ready_for_merge` MCP tool (`internal/agents/ready.go`), whose `overview` covers the whole task for a human switching between tasks: a summary, tradeoffs and compromises, what was not done, and future TODOs. Its description tells the agent to call it only once the review of its most recent changes has come back, never in the same turn as a change or while that review is pending. Each call records a `task.ready` event carrying it, which the lead gets as a signal and the transcript draws as a bubble (`ReadyBubble` in `mac/Sources/Byre/Chat.swift`) headed "ready for merge". The latest one, while the task is active and no question, `task.ready_cleared` or snapshot other than an `answer`, `fork` or `ask_human` one (the never-reviewed kinds) has come since it (a question, a withdrawal or dismissal, or newer work the overview doesn't cover), is drawn like an open question (the warn tint and border) and carries an **Other…** field and small Dismiss, Discard, **Merge & continue** and **Merge & complete** buttons; earlier ones are drawn like an answered question (the faint warn tint), without them. **Merge & complete** is the header's Merge, dialog included. **Merge & continue** opens the same dialog without the parent and After review choices and sends `keep_open` (see `merge_to_main` below). The tool's optional `merge` (`complete`, the default, or `continue`) is carried on the event and picks which of the two the card highlights with an accent border. Text sent from **Other…** (Return sends, Shift-Return is a newline) dismisses the ready call (`DELETE /api/tasks/{id}/ready`) and then goes to the agent as a chat message (`Store.messageTask`). While the card is current, the composer is hidden by default as it is for an open question (`ChatModel.composerHiddenByDefault`). Recording it changes nothing else. byre asks for it (`readyPrompt`, sent as a byre message) when a task's state goes to `idle`, `ready_to_merge` or `discard_suggested` (`readyState`) or its workflow finishes (`Manager.Observe` calls `promptReady`) and the latest reviewable snapshot's review is `done` or `skipped`. It checks and sends under the session's delivery lock (the one human messages and answers take), and only to an agent that isn't running, so the prompt never queues behind a turn that could add unreviewed work. `tasks.ready_prompt_seq` holds the task's last event seq when the prompt's turn started; byre asks again only once a reviewable snapshot or a message from the human (a chat message or a question's answer, not one from byre, the agent or the lead) has come after it, so the agent's own turns that change no files don't ask again, and a human unblocking an agent that said it was waiting does. It doesn't ask while the task has an open question. It doesn't ask for a workflow stage task until its run has a `task.workflow_finished` event (by then the run's other stage tasks are being discarded, leaving the final one for the human), a task in any other state (an open question, a held merge or a merge after review among them), or a review that failed, and it skips the `reviewing` → `idle` change of a review with findings, since those go to the agent; the turn that answers them asks once it ends without new work. The prompt tells the agent not to call the tool, and to say what's left, when the task isn't ready. When that review is `skipped` (the snapshot has no diff from the default branch), byre sends `readyPromptUnchanged` instead, which says there is nothing to merge and offers only `suggest_discard` and `ask_human`. -An implementer that thinks its task shouldn't land at all (the approach failed, the task turned out unnecessary or already done) says so with the `suggest_discard` MCP tool (also `internal/agents/ready.go`), whose `reason` explains why; `readyPrompt` offers it as the alternative to `ready_for_merge`. Each call records a `task.discard_suggested` event, which the lead gets as a signal and the transcript draws with the same `ReadyBubble`, headed "suggests discarding". It takes part in "latest" with the ready-for-merge overviews, so whichever of the two came last is the current one, drawn and cleared under the same rules, and it carries only the Discard button. It only suggests: the task stays open until the human discards it. +An implementer that thinks its task shouldn't land at all (the approach failed, the task turned out unnecessary or already done) says so with the `suggest_discard` MCP tool (also `internal/agents/ready.go`), whose `reason` explains why; `readyPrompt` offers it as the alternative to `ready_for_merge`. Each call records a `task.discard_suggested` event, which the lead gets as a signal and the transcript draws with the same `ReadyBubble`, headed "suggests discarding". It takes part in "latest" with the ready-for-merge overviews, so whichever of the two came last is the current one, drawn and cleared under the same rules, and it carries the **Other…** field and only the Dismiss and Discard buttons. It only suggests: the task stays open until the human discards it. `tasks.ready` is `merge` or `discard` after the latest of these two calls, set in the same transaction as its event, and is cleared by the same things that clear the latest bubble: a snapshot other than an `answer`, `fork` or `ask_human` one, a new inbox item, or a `task.ready_cleared` event. That event comes from the agent's `cancel_ready` MCP tool (also `internal/agents/ready.go`, for when its call no longer holds) or from `DELETE /api/tasks/{taskId}/ready`, which the current bubble's small Dismiss button sends; the lead gets it as a signal and the transcript draws it as a note. It feeds the task's state (`ready_to_merge` or `discard_suggested`, see "Task state in the store"), which the switcher's and the Project tab's task rows show as **Ready to merge** or **Ready to discard**, in the warn colour with a warn dot like **Needs your answer**. The app reloads a project's tasks on `task.ready`, `task.discard_suggested`, `task.ready_cleared`, `snapshot.created` and `task.state`. @@ -97,6 +97,8 @@ 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 `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 diff --git a/internal/agents/compact.go b/internal/agents/compact.go index 85ed24a..fbff71f 100644 --- a/internal/agents/compact.go +++ b/internal/agents/compact.go @@ -37,7 +37,7 @@ func (m *Manager) Compact(ctx context.Context, sessionID, model string) (api.Age if sess.Owner != "" { prompt = leadHandoffPrompt } - err = transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateRunning, "byre") + err = m.startRunning(ctx, sess, task.Id, "byre") if err != nil { return api.AgentSession{}, err } diff --git a/internal/agents/manager.go b/internal/agents/manager.go index 60af700..33dcf8c 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -63,6 +63,7 @@ type Manager struct { finishing map[string]bool handoffModel map[string]string mergeTurns map[string]mergeTurn + landing map[string]landing deliverMu sync.Mutex deliverLocks map[string]*sessionLock @@ -158,6 +159,7 @@ func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome, antigrav finishing: map[string]bool{}, handoffModel: map[string]string{}, mergeTurns: map[string]mergeTurn{}, + landing: map[string]landing{}, signals: make(chan []api.Event, 1024), snaps: map[string]*snapState{}, merges: map[string]*projectMerge{}, @@ -537,13 +539,32 @@ func (m *Manager) Usage(ctx context.Context) (Usage, error) { } func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) { - err := transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateRunning, actor) + err := m.startRunning(ctx, sess, task.Id, actor) if err != nil { return api.AgentSession{}, err } return m.launch(ctx, sess, task, actor, text, images) } +func (m *Manager) startRunning(ctx context.Context, sess api.AgentSession, taskID, actor string) error { + held := fmt.Errorf("%w: held while this task merges and continues", ErrConflict) + if m.heldForLanding(taskID, sess.Id) { + return held + } + err := transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateRunning, actor) + if err != nil { + return err + } + if m.heldForLanding(taskID, sess.Id) { + err = transition(ctx, m.store, sess.Id, api.AgentSessionStateRunning, api.AgentSessionStateIdle, actor) + if err != nil { + return err + } + return held + } + return nil +} + func (m *Manager) launch(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) { l, err := m.ensureLive(ctx, sess, task) if err == nil { @@ -1070,6 +1091,8 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error { return m.reviewTurnEnded(ctx, l, res) } + defer m.endLanding(l.taskID, l.id) + m.mu.Lock() interrupted := l.interrupted rateLimited := l.rateLimited @@ -1091,11 +1114,11 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error { m.endMergeTurn(l.id, false) } }() - if !finished { - err := git.AbortMerge(l.dir) - if err != nil { - slog.Error("abort unfinished merge", "session", l.id, "err", err) - } + } + if (owner || m.landingBy(l.taskID, l.id) != "") && !finished { + err := git.AbortMerge(l.dir) + if err != nil { + slog.Error("abort unfinished merge", "session", l.id, "err", err) } } unlock := func() {} diff --git a/internal/agents/mergemain.go b/internal/agents/mergemain.go index ede8400..c81af08 100644 --- a/internal/agents/mergemain.go +++ b/internal/agents/mergemain.go @@ -5,10 +5,12 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/git" "g.fc.run/ai/byre/internal/mcp" + "g.fc.run/ai/byre/internal/store" ) type mergeToMainArgs struct { @@ -52,7 +54,11 @@ func (m *Manager) mergeToMain(ctx context.Context, l *live, raw json.RawMessage) if err != nil { return "", err } - user, err := m.store.GetUser(ctx, sess.CreatedBy) + login := sess.CreatedBy + if by := m.landingBy(l.taskID, l.id); by != "" { + login = by + } + user, err := m.store.GetUser(ctx, login) if err != nil { return "", err } @@ -168,3 +174,116 @@ func (m *Manager) pushToMain(ctx context.Context, l *live, task api.Task, projec } return commit, nil } + +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. "+ + "If resolving the conflicts took substantive changes rather than combining both sides as written, stop there: end your turn with a description of those changes, so the review covers them before anything lands. "+ + "Otherwise call merge_to_main with title %[2]q, which commits the merge and lands the work on %[1]s, then end your turn with one line.", target, title) +} + +func (m *Manager) MergeToMain(ctx context.Context, task api.Task, project api.Project, user api.User, title string) (api.Task, error) { + if user.GitName == "" || user.GitEmail == "" { + return api.Task{}, ErrNoGitIdentity + } + title, err := cleanMergeTitle(title) + if err != nil { + return api.Task{}, err + } + if task.Merging || task.MergeQueuedAt != nil || task.MergingIntoParent || task.Discarding { + return api.Task{}, fmt.Errorf("%w: task %s is %s", ErrConflict, task.Id, task.State) + } + sess, found, err := m.liveSession(ctx, task.Id) + if err != nil { + return api.Task{}, err + } + if !found { + return api.Task{}, fmt.Errorf("%w: task %s has no agent", ErrConflict, task.Id) + } + + unlock := m.lockDelivery(sess.Id) + defer unlock() + + m.mu.Lock() + holder := m.landing[task.Id] + if holder.session == "" { + m.landing[task.Id] = landing{session: sess.Id, login: user.Login} + } + m.mu.Unlock() + if holder.session != "" { + return api.Task{}, fmt.Errorf("%w: task %s is already merging and continuing", ErrConflict, task.Id) + } + sessions, err := m.store.ListSessions(ctx, task.Id) + if err == nil { + for _, s := range sessions { + if s.Role == api.Implementer && s.State == api.AgentSessionStateRunning { + err = fmt.Errorf("%w: an agent on this task is mid-turn; merge when they're all idle", ErrConflict) + } + } + } + if err == nil { + m.clearMergeAfterReview(ctx, task.Id) + _, err = m.Send(ctx, sess.Id, "byre", landPrompt(project.DefaultBranch, title), "") + } + if err != nil { + m.endLanding(task.Id, sess.Id) + return api.Task{}, err + } + + err = m.store.ClearReady(ctx, task.Id, user.Login) + if err != nil && !errors.Is(err, store.ErrNotFound) { + return api.Task{}, err + } + return m.store.GetTask(ctx, task.Id) +} + +type landing struct { + session string + login string +} + +func (m *Manager) heldForLanding(taskID, sessionID string) bool { + m.mu.Lock() + defer m.mu.Unlock() + holder := m.landing[taskID].session + return holder != "" && holder != sessionID +} + +func (m *Manager) landingBy(taskID, sessionID string) string { + m.mu.Lock() + defer m.mu.Unlock() + if m.landing[taskID].session != sessionID { + return "" + } + return m.landing[taskID].login +} + +func (m *Manager) endLanding(taskID, sessionID string) { + m.mu.Lock() + held := m.landing[taskID].session == sessionID + if held { + delete(m.landing, taskID) + } + m.mu.Unlock() + if held { + go m.releaseTeam(taskID, sessionID) + } +} + +func (m *Manager) releaseTeam(taskID, landingID string) { + ctx := context.Background() + sessions, err := m.store.ListSessions(ctx, taskID) + if err != nil { + slog.Error("list sessions after merge and continue", "task", taskID, "err", err) + return + } + for _, s := range sessions { + if s.Id == landingID || 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 after merge and continue", "session", s.Id, "err", err) + } + } +} diff --git a/internal/agents/queue.go b/internal/agents/queue.go index 1d2751c..7013e2e 100644 --- a/internal/agents/queue.go +++ b/internal/agents/queue.go @@ -53,7 +53,7 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model str return api.AgentSession{}, false, err } } - held := sess.State == api.AgentSessionStateIdle && sess.Role == api.Implementer && automatedActor(actor) && m.interrupted(sess.Id) + held := sess.State == api.AgentSessionStateIdle && sess.Role == api.Implementer && (automatedActor(actor) && m.interrupted(sess.Id) || m.heldForLanding(sess.TaskId, sess.Id)) if !held && (sess.State != api.AgentSessionStateRunning || sess.Role != api.Implementer) { sess, err = m.Send(ctx, sessionID, actor, text, model, images...) return sess, false, err diff --git a/internal/agents/ready.go b/internal/agents/ready.go index d39e8b5..16a73c4 100644 --- a/internal/agents/ready.go +++ b/internal/agents/ready.go @@ -26,6 +26,7 @@ const readyPromptUnchanged = "Your turn has ended, and this task's branch has no type readyArgs struct { Overview string `json:"overview"` + Merge string `json:"merge"` } func (m *Manager) readyForMergeTool(l *live) mcp.Tool { @@ -33,12 +34,13 @@ func (m *Manager) readyForMergeTool(l *live) mcp.Tool { Name: "ready_for_merge", Description: "Tell the human this task is ready to merge, with an overview of the whole task: all of the branch's work, not just what changed since your last call or the last discussion. " + "Write it for someone jumping between tasks who hasn't followed this one: a summary of what the change does, the tradeoffs and compromises made, what was not done, and follow-up work for later. " + - "byre shows it in the task's transcript and passes it to the human's lead agent. " + + "byre shows it in the task's transcript, with buttons to merge and complete the task or merge and continue it, and passes it to the human's lead agent. " + "Call it only once the review of your most recent changes has come back: byre reviews the work after each turn that changes files and messages you when that review finishes, so never call it in the same turn as a change, or while a review of your latest work is pending.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "overview": {"type": "string", "description": "Markdown, with sections for the summary, tradeoffs and compromises, not done, and future TODOs; leave out a section with nothing in it."} + "overview": {"type": "string", "description": "Markdown, with sections for the summary, tradeoffs and compromises, not done, and future TODOs; leave out a section with nothing in it."}, + "merge": {"type": "string", "enum": ["complete", "continue"], "description": "The button byre highlights: complete (the default) lands the work and closes the task; continue lands the work so far and keeps the task open, for when you or the human mean to do more on it."} }, "required": ["overview"] }`), @@ -52,7 +54,13 @@ func (m *Manager) readyForMergeTool(l *live) mcp.Tool { if overview == "" { return "", fmt.Errorf("%w: overview must not be empty", ErrInvalid) } - err = m.store.MarkReady(ctx, l.taskID, overview, agentActor) + if args.Merge == "" { + args.Merge = "complete" + } + if args.Merge != "complete" && args.Merge != "continue" { + return "", fmt.Errorf("%w: merge must be complete or continue", ErrInvalid) + } + err = m.store.MarkReady(ctx, l.taskID, overview, args.Merge, agentActor) if errors.Is(err, store.ErrNotFound) { return "", fmt.Errorf("%w: this task is closed", ErrConflict) } diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index d981114..b3429e5 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -1683,6 +1683,9 @@ type MergeTaskJSONBody struct { // AfterReview Merge once the running review of the latest snapshot finishes with no findings, instead of now. AfterReview *bool `json:"after_review,omitempty"` + // KeepOpen Land the work so far and keep the task open, instead of closing it. + KeepOpen *bool `json:"keep_open,omitempty"` + // MergeTitle The squash commit's title: one line, at most 72 characters. Required for target main. MergeTitle *string `json:"merge_title,omitempty"` @@ -4376,52 +4379,55 @@ var swaggerSpec = []string{ "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/server.go b/internal/server/server.go index 72be8ca..171b2ed 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -504,6 +504,23 @@ func (s *Server) MergeTask(w http.ResponseWriter, r *http.Request, taskID string writeError(w, http.StatusBadRequest, fmt.Errorf("unknown target %q", *req.Target)) return } + if req.KeepOpen != nil && *req.KeepOpen { + if (req.Target != nil && *req.Target != api.MergeTargetMain) || (req.AfterReview != nil && *req.AfterReview) { + writeError(w, http.StatusBadRequest, errors.New("keep_open is only for target main, without after_review")) + return + } + if req.MergeTitle == nil { + writeError(w, http.StatusBadRequest, errors.New("merge_title is required to merge into the default branch")) + return + } + t, err = s.agents.MergeToMain(r.Context(), t, p, u, *req.MergeTitle) + if err != nil { + writeAgentError(w, err) + return + } + writeJSON(w, http.StatusAccepted, t) + return + } if req.AfterReview != nil && *req.AfterReview { target := api.MergeTargetMain if req.Target != nil { diff --git a/internal/store/store.go b/internal/store/store.go index c8aaa16..8bda09e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -781,8 +781,8 @@ func (s *Store) SetReadyPromptSeq(ctx context.Context, id string, seq int64) err return err } -func (s *Store) MarkReady(ctx context.Context, id, overview, actor string) error { - return s.setReady(ctx, id, actor, "merge", "task.ready", map[string]any{"id": id, "overview": overview}) +func (s *Store) MarkReady(ctx context.Context, id, overview, merge, actor string) error { + return s.setReady(ctx, id, actor, "merge", "task.ready", map[string]any{"id": id, "overview": overview, "merge": merge}) } func (s *Store) SuggestDiscard(ctx context.Context, id, reason, actor string) error { diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index ba951d4..d9c7078 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -323,7 +323,7 @@ final class ChatModel { if let r = row.review, r.id == latest?.id, findingOpen(r, i) { open.append(row.id) } - case .ready(_, _, let d) where row.id == builder.lastReady && active: + case .ready(_, _, let d, _) where row.id == builder.lastReady && active: ready.append(row.id) discard = d default: @@ -376,7 +376,7 @@ final class ChatModel { guard let taskId, let task = store.findTask(taskId) else { return false } - return task.status != .active || store.inbox.contains(where: { $0.taskId == taskId }) + return task.status != .active || store.inbox.contains(where: { $0.taskId == taskId }) || attention().contains { $0.id == "ready" } } func showStatus(_ s: ChatStatus) { @@ -637,6 +637,8 @@ private struct MergeButton: View { let busy: Bool let unmerged: Bool var small = false + var keepOpen: Bool? + var highlight = false @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @@ -644,6 +646,22 @@ private struct MergeButton: View { let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch" let held = task.mergeQueuedAt != nil let intoParent = task.mergingIntoParent + if keepOpen == true { + Button("Merge & continue") { + Task { + await confirmMerge(task.id, store, overlays, keepOpen: true) + } + } + .buttonStyle(SmallLinkButtonStyle(border: highlight ? Palette.accent : Palette.border)) + .disabled(busy || held || intoParent || !unmerged) + .help("The agent merges \(target) in, then squashes the work so far into one commit on \(target) and pushes it, keeping the task open; the task's other agents wait until it's done") + } else { + button(target, held, intoParent) + } + } + + @ViewBuilder + private func button(_ target: String, _ held: Bool, _ intoParent: Bool) -> some View { let help = intoParent ? "Waiting for the parent task's agent to merge this subtask" : held @@ -653,14 +671,14 @@ private struct MergeButton: View { : task.parentTaskId != nil ? "Merge this subtask into its parent task's branch, or into \(target) (⌘S)" : "The agent merges \(target) in, then byre squashes into one commit on \(target) and pushes it to origin; held while another task in the project is merging (⌘S)" - let button = Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : "Merge") { + let button = Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : keepOpen == false ? "Merge & complete" : "Merge") { Task { await confirmMerge(task.id, store, overlays) } } Group { if small { - button.buttonStyle(SmallLinkButtonStyle()) + button.buttonStyle(SmallLinkButtonStyle(border: highlight ? Palette.accent : Palette.border)) } else { button.buttonStyle(WebButtonStyle()) } @@ -1280,8 +1298,8 @@ struct RowView: View { case .findingResponse(let snapshot, let index, let response, let reason): FindingResponseView(row: row, model: model, snapshot: snapshot, index: index, response: response, reason: reason) - case .ready(let when, let blocks, let discard): - ReadyBubble(when: when, blocks: blocks, discard: discard, taskId: row.taskId, latest: row.id == model.builder.lastReady, unmerged: model.hasUnmerged) + case .ready(let when, let blocks, let discard, let keepOpen): + ReadyBubble(when: when, blocks: blocks, discard: discard, keepOpen: keepOpen, taskId: row.taskId, latest: row.id == model.builder.lastReady, unmerged: model.hasUnmerged) case .handoff: DetailsBox(boxed: false) { @@ -1413,12 +1431,15 @@ private struct ReadyBubble: View { let when: Date let blocks: [MDBlock] let discard: Bool + let keepOpen: Bool let taskId: String? let latest: Bool let unmerged: Bool @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var dismissing = false + @ViewState private var other: String? + @FocusState private var editing: Bool var body: some View { let task = taskId.flatMap { store.findTask($0) } @@ -1435,26 +1456,21 @@ private struct ReadyBubble: View { MarkdownView(blocks: blocks, ctx: MDContext(taskId: taskId)) if current, let task { let busy = store.taskBusy(task.id) + otherEntry(task) + .padding(.top, 4) HStack(spacing: 8) { Spacer(minLength: 0) Button("Dismiss") { - dismissing = true - Task { - do { - _ = try await store.api.client.dismissReady(path: .init(taskId: task.id)).ok - } catch { - overlays.showAlert(errorText(error)) - } - dismissing = false - } + dismiss(task, then: nil) } .buttonStyle(SmallLinkButtonStyle()) .disabled(dismissing) .help(discard ? "Drop this suggestion; the task stays open" : "Drop this ready call; the task stays open") - if !discard { - MergeButton(task: task, busy: busy, unmerged: unmerged, small: true) - } DiscardButton(task: task, busy: busy, small: true) + if !discard { + MergeButton(task: task, busy: busy, unmerged: unmerged, small: true, keepOpen: true, highlight: keepOpen) + MergeButton(task: task, busy: busy, unmerged: unmerged, small: true, keepOpen: false, highlight: !keepOpen) + } } } } @@ -1464,6 +1480,76 @@ private struct ReadyBubble: View { .background(current ? Palette.warnTint : Palette.warnFaint, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(current ? Palette.warn.opacity(0.55) : Palette.border)) } + + @ViewBuilder + private func otherEntry(_ task: ByreTask) -> some View { + if let text = other { + TextEditor(text: Binding(get: { text }, set: { other = $0 })) + .font(Fonts.font(13)) + .scrollContentBackground(.hidden) + .writingToolsBehavior(.disabled) + .focused($editing) + .disabled(dismissing) + .onKeyPress(.return, phases: .down) { press in + let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false + if press.modifiers.contains(.shift) || marked || trimmed(text).isEmpty { + return .ignored + } + dismiss(task, then: text) + return .handled + } + .padding(.horizontal, 5) + .padding(.vertical, 6) + .frame(height: 66) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(alignment: .topLeading) { + if text.isEmpty { + Text("Something else to tell the agent; sending it dismisses this") + .foregroundStyle(Palette.muted) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .allowsHitTesting(false) + } + } + .overlay(RoundedRectangle(cornerRadius: 6).stroke(editing ? Palette.accent : Palette.border)) + .onChange(of: editing) { + if !editing && trimmed(other ?? "").isEmpty { + other = nil + } + } + } else { + Text("Other…") + .foregroundStyle(Palette.muted) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) + .contentShape(Rectangle()) + .onTapGesture { + other = "" + DispatchQueue.main.async { + editing = true + } + } + } + } + + private func dismiss(_ task: ByreTask, then text: String?) { + dismissing = true + Task { + do { + _ = try await store.api.client.dismissReady(path: .init(taskId: task.id)).ok + if let text { + try await store.messageTask(task.id, text) + other = nil + } + } catch { + overlays.showAlert(errorText(error)) + } + dismissing = false + } + } } private struct ToolResult: View { diff --git a/mac/Sources/Byre/MergeDialog.swift b/mac/Sources/Byre/MergeDialog.swift index fb06288..b767c41 100644 --- a/mac/Sources/Byre/MergeDialog.swift +++ b/mac/Sources/Byre/MergeDialog.swift @@ -21,6 +21,7 @@ struct MergeWarning { final class MergeModel { let taskId: String let target: String + let keepOpen: Bool let parentTitle: String? let coAuthors: [Participant] let original: String @@ -32,9 +33,10 @@ final class MergeModel { @ObservationIgnored var leave: Leave = { _ in } @ObservationIgnored private var loads = 0 - init(taskId: String, target: String, parentTitle: String?, coAuthors: [Participant], title: String) { + init(taskId: String, target: String, keepOpen: Bool, parentTitle: String?, coAuthors: [Participant], title: String) { self.taskId = taskId self.target = target + self.keepOpen = keepOpen self.parentTitle = parentTitle self.coAuthors = coAuthors original = title @@ -72,7 +74,7 @@ final class MergeModel { } @MainActor -func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async { +func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays, keepOpen: Bool = false) async { guard let task = store.findTask(taskId), task.status == .active else { return } @@ -107,7 +109,7 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async } let title = mergeDrafts[taskId] ?? task.mergeTitle mergeDrafts[taskId] = nil - let model = MergeModel(taskId: taskId, target: target, parentTitle: parent?.status == .active ? parent?.name : nil, coAuthors: coAuthors, title: title) + let model = MergeModel(taskId: taskId, target: target, keepOpen: keepOpen, parentTitle: !keepOpen && parent?.status == .active ? parent?.name : nil, coAuthors: coAuthors, title: title) model.leave = { [weak model] go in if let model { mergeDrafts[taskId] = model.sentTitle @@ -131,13 +133,19 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async let merge = { (afterReview: Bool) in let body: Operations.MergeTask.Input.Body.JsonPayload = model.toParent ? .init(target: .parent, afterReview: afterReview ? true : nil) - : .init(mergeTitle: model.sentTitle, afterReview: afterReview ? true : nil) + : .init(mergeTitle: model.sentTitle, afterReview: afterReview ? true : nil, keepOpen: keepOpen ? true : nil) do { _ = try await store.api.client.mergeTask(path: .init(taskId: taskId), body: .json(body)).accepted } catch { overlays.showAlert(errorText(error)) } } + if keepOpen { + if await overlays.showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge & continue", value: "ok", primary: true)], inPane: true, canSubmit: { model.valid }) == "ok" { + await merge(false) + } + return + } if !model.running { if await overlays.showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge", value: "ok", primary: true)], inPane: true, canSubmit: { model.valid }) == "ok" { await merge(false) @@ -245,7 +253,7 @@ private struct MergeContent: View { .padding(.bottom, 16) } if !model.toParent { - Text("Merge into \(model.target) as:") + Text(model.keepOpen ? "Merge the work so far into \(model.target), keeping the task open, as:" : "Merge into \(model.target) as:") .foregroundStyle(Palette.muted) .padding(.bottom, 4) titleRow diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index 748d490..9f20563 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -53,7 +53,7 @@ enum RowKind { case review(note: Note, md: [MDBlock]) case finding(index: Int) case findingResponse(snapshot: Int, index: Int, response: String, reason: String) - case ready(when: Date, md: [MDBlock], discard: Bool) + case ready(when: Date, md: [MDBlock], discard: Bool, keepOpen: Bool) case handoff case snapshot(Snapshot) case question @@ -822,10 +822,10 @@ final class TranscriptBuilder { return note([.text("\(ev.actor) set the task summary")]) case "task.ready": - return Row(.ready(when: when, md: parseMarkdown(jsString(p["overview"])), discard: false), taskId: rowTask) + return Row(.ready(when: when, md: parseMarkdown(jsString(p["overview"])), discard: false, keepOpen: str(p, "merge") == "continue"), taskId: rowTask) case "task.discard_suggested": - return Row(.ready(when: when, md: parseMarkdown(jsString(p["reason"])), discard: true), taskId: rowTask) + return Row(.ready(when: when, md: parseMarkdown(jsString(p["reason"])), discard: true, keepOpen: false), taskId: rowTask) case "task.ready_cleared": return note([.text(ev.actor == "agent" ? "the agent withdrew its ready call" : "\(ev.actor) dismissed the ready call")])