diff --git a/docs/operations.md b/docs/operations.md index 2ef8c65..7359f71 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -40,7 +40,7 @@ An implementer says its task is ready to merge with the `ready_for_merge` MCP to 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`. +`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 no note for it, only taking the warn tint off the bubble. 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`. Agents find closed tasks with the `search_closed_tasks` MCP tool, which task agents get for their own project and the lead for one project (`project_id`) or all: a case-insensitive substring match of `query` on title, merge title, TODO item, summary and merge commit (`Store.SearchClosedTasks`), newest closed first, at most `limit` (default 20, at most 100), each with its id, outcome, merge commit, close time, creator, titles, TODO item, fork source and summary. The lead reads a closed task's transcript with `read_transcript`. There is no closed-task list in the UI; the human asks the lead. @@ -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` 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 `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 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, a 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 no review of the latest snapshot is running. Asking again replaces the request. Whenever a review finishes (submitted, failed, interrupted, or failed by a restart or the task closing), `reviewFinished` first starts the review workflow's next stages (see "Reviews") and waits for them; once none of that snapshot's reviews is running, it takes the request if it names one of them (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 every review of the snapshot is `done` with no findings between them, the 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/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index ed362de..c7ad365 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -554,7 +554,7 @@ final class TranscriptBuilder { continue } } - if ev.kind == "task.ready_cleared" && str(p, "reason") == "merge" { + if ev.kind == "task.ready_cleared" { if sink == nil && own { lastReady = nil } @@ -657,7 +657,7 @@ final class TranscriptBuilder { if (ev.kind == "task.ready" || ev.kind == "task.discard_suggested") && sink == nil && own { lastReady = row.id } - if (ev.kind == "inbox.created" || ev.kind == "task.ready_cleared") && sink == nil && own { + if ev.kind == "inbox.created" && sink == nil && own { lastReady = nil } if prompt && !row.hidden || agentNote { @@ -967,9 +967,6 @@ final class TranscriptBuilder { case "task.discard_suggested": 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")]) - case "task.discard_started": return note([.text("\(ev.actor) asked to discard the task; the agent writes the task summary first")])