Fold task state into one server field; add ready cancel and dismiss

This commit is contained in:
Ian Gulliver
2026-10-20 08:51:32 -07:00
parent 224a0d5a4b
commit e7724977c8
12 changed files with 506 additions and 388 deletions
+4 -4
View File
@@ -37,11 +37,11 @@ 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 neither a question nor a snapshot other than an answer snapshot has come since it (a question, or newer work the overview doesn't cover), is drawn like an open question (the warn tint and border) and carries small 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` or its workflow finishes (`Manager.Observe` calls `promptReady`) and the latest non-answer 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 non-answer 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 with a merge after review set, 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.
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 snapshot 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 non-answer 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 non-answer 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.
A Task's `ready` (`tasks.ready`) is `merge` or `discard` after the latest of these two calls, set in the same transaction as its event, and is cleared (absent in the API) by the same things that clear the latest bubble: a snapshot other than an answer snapshot, or a new inbox item. The switcher's and the Project tab's task rows show it as a **Ready to merge** or **Ready to discard** state, in the warn colour with a warn dot like **Needs your answer**, when no question, merge or running work outranks it (`taskState` in `mac/Sources/Byre/Store.swift`). The app reloads a project's tasks on `task.ready`, `task.discard_suggested`, `snapshot.created` and `inbox.created`.
`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 snapshot, 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`.
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.
@@ -69,7 +69,7 @@ A Task's `merging` is true while `merging_by` or `finalizing` is set, and `disca
### Task state in the store
A Task's `state` (the `tasks.state` column) is what the task is doing, owned by the store: `merged` or `discarded` once closed, else `discarding`, `merging`, the state of the agent's latest non-completed implementer session when it isn't `idle` (`running`), `running` while that session's `subagents` count is above zero (see "Subagent count"), `reviewing` while a review is `running`, `review_pending` while `tasks.review_requests` is above zero, `idle`, or `no_agent` when the task has no live session (`taskStateExpr` in `internal/store/store.go`, earlier cases winning). Every write transaction (`Store.write`), after its own changes, recomputes the state of each task its events name and, when it changed, updates the column and emits `task.state` (`from`, `to`; actor that of the task's last event in the transaction) in the same transaction, so the state and the events that changed it commit and publish together, in seq order. The store publishes each transaction's events in one call (`onEvent` takes the list), which the lead's signals rely on.
A Task's `state` (the `tasks.state` column) is the one state the task is in, owned by the store and read as is by the app, the lead and the API: `merged` or `discarded` once closed, else `discarding`, `asking` while it has an open inbox item, `merging` while `merging_by`, `finalizing` or `parent_merge_by` is set, `merge_held` while `merge_queued_by` is set, `merge_after_review` while `merge_after_review_by` is set, the state of the agent's latest non-completed implementer session when it isn't `idle` (`running`), `running` while that session's `subagents` count is above zero (see "Subagent count"), `reviewing` while a review is `running`, `review_pending` while `tasks.review_requests` is above zero, `ready_to_merge` or `discard_suggested` while `tasks.ready` is `merge` or `discard`, `idle`, or `no_agent` when the task has no live session (`taskStateExpr` in `internal/store/store.go`, earlier cases winning). Every write transaction (`Store.write`), after its own changes, recomputes the state of each task its events name and, when it changed, updates the column and emits `task.state` (`from`, `to`; actor that of the task's last event in the transaction) in the same transaction, so the state and the events that changed it commit and publish together, in seq order. The store publishes each transaction's events in one call (`onEvent` takes the list), which the lead's signals rely on.
Subagent count: Claude Code's subagents can run in the background past the `turn.ended` of the turn that started them, while the session is `idle`. A session's `subagents` (the `agent_sessions.subagents` column, on `AgentSession`) counts its running ones: `trackSubagents` in `internal/agents/manager.go` adds a `task_started` `system` event's `task_id` when its `task_type` is `local_agent` (background shell commands are `local_bash` and don't count), and drops it on the `task_notification` naming it. Each change is written with `Store.SetSubagents`, which emits `session.subagents` (`subagents`, the new count). It drops to 0 when the agent's process exits, and on startup for every session left above 0, since a restart ends every process. `sqlite3 byre.db "SELECT id, task_id, owner, subagents FROM agent_sessions WHERE subagents > 0"` shows the sessions with subagents running.
@@ -280,7 +280,7 @@ Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and
The task switcher (⌘K, or the project ▸ task picker centred in the right pane's top row) lists active tasks with their task state and open inbox item count (the Project tab shows the same state), live while it is open. Tasks are grouped by state (`taskRank` in `mac/Sources/Byre/Store.swift`, which also orders the Project tab's task list, ties in the server's order, subtasks sorted among their siblings under their parent): with inbox items first, then **Ready to merge** or **Ready to discard**, then idle, then working or reviewing, and merging, held or discarding last; within each group, the task least recently visited comes first (never-visited tasks before all others, the right pane's own task after all others, ties in the server's order: projects by name, tasks newest first), so ⌘K Enter repeated cycles through the tasks. Visit times are SwiftData `VisitRecord`s synced through iCloud (`docs/macos.md`, "Persistence"), stamped when a task is selected and again when it is left. After the tasks come the projects, one row each; picking one points the right pane at the project with no task, on the Project tab. Typing filters both and adds a last row that creates a task with the typed text as its title, in the right pane's project (the first project when there is none); Enter picks the highlighted row. Text before a first colon that starts at least one project's name (ignoring case, no spaces) picks those projects instead: `by: fix` lists only their tasks whose titles contain "fix", their project rows that match, and one row per project creating the task titled "fix" there. When no project name starts with it, the whole text is the title, so "Fix: login" stays as typed. A create row shows the user's default model (see "Agent sessions"), the model the composer offers first for the new task's first message; the task itself starts with no session. Below each create row, a "Start in …" row creates the same task and starts its agent on the default model with the title as its first message, through `POST /api/projects/{id}/tasks` with `prompt` set to the title. Picking a task points the right pane at it on the Transcript tab, scrolled to its oldest open question when it has open inbox items, otherwise to its latest summary when that is unread (see below and "Lead agent").
**Task state.** The switcher and the Project tab show one label per task in place of its raw session state, the first that applies (`taskState` in `mac/Sources/Byre/Store.swift`), with a dot and a tooltip saying why: **Needs your answer** (an open `question` inbox item), **Merging** (`merging` or `merging_into_parent`), **Merge held** (`merge_queued_at`), **Merge after review** (`merge_after_review`), **Reviewing** (the task's `state` is `review_pending` or `reviewing`), **Working** (`running`), **Discarding** (`discarding`; see "Task state in the store" for both), **Idle** (anything else, including no session). The Project tab, in its task list and TODO items, draws a spinner in place of the dot for **Reviewing**, **Working**, **Discarding** and **Merging**. The label follows the tasks and inbox state the app already holds, so it needs no extra fetch.
**Task state.** The switcher and the Project tab show one label per task for its `state` (see "Task state in the store"; `stateTitles` in `mac/Sources/Byre/Store.swift`), with a dot and a tooltip saying why: **Needs your answer** (`asking`), **Merging** (`merging`), **Merge held** (`merge_held`), **Merge after review** (`merge_after_review`), **Reviewing** (`review_pending` or `reviewing`), **Working** (`running`), **Discarding** (`discarding`), **Ready to merge** (`ready_to_merge`), **Ready to discard** (`discard_suggested`), **Idle** (`idle` or `no_agent`). The Project tab, in its task list and TODO items, draws a spinner in place of the dot for **Reviewing**, **Working**, **Discarding** and **Merging**. The label follows the tasks the app already holds, so it needs no extra fetch.
**Unread summaries.** A Task's `summary_seq` is the seq of its latest `turn.ended` event with `summary: true` (0 for none), read through the partial index `events_summary`, so the app knows of unread summaries without loading transcripts; a `turn.ended` with `summary: true` on the global stream raises it in the app. The switcher's and the Project tab's task rows show a **new summary** state label in place of Idle while the task is idle and `summary_seq` is above the seq the user has read (`unreadSummary` in `mac/Sources/Byre/Store.swift`), except on tasks of a workflow run, which close too soon for their summaries to be read. A summary isn't an attention item: picking such a row opens the Transcript at the end, like any task without open questions, findings or a ready bubble. Read state is per user on the server: `summary_reads` holds one highest-read seq per user and task. `GET /api/me/summary-reads` returns the caller's, for active tasks, as a map from task id to seq, and `PUT /api/tasks/{taskId}/summary-read` with `{seq}` raises it, emitting `summary.read` (`task_id`, `seq`) owned by the user only when it went up, so every Mac the user has byre open on clears the label. A summary counts as read only when the summary turn's end row is on screen in a transcript while that server's window is the key window of the active app (`summaryVisible` in `mac/Sources/Byre/Chat.swift`), so selecting the task on another tab leaves it unread. `sqlite3 byre.db 'SELECT * FROM summary_reads'` shows the read seqs.