Add Merge & continue and Other… to ready cards

This commit is contained in:
Ian Gulliver
2026-10-20 11:01:59 -07:00
parent 25f275e012
commit ef98b1179d
13 changed files with 363 additions and 88 deletions
+4 -2
View File
@@ -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