task description auto

This commit is contained in:
Ian Gulliver
2026-10-20 08:57:37 -07:00
parent 8f3226350e
commit d530ec5edc
20 changed files with 434 additions and 342 deletions
+1 -1
View File
@@ -149,7 +149,7 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima
## Keyboard and menus
The app menu has About, Check for Updates…, Forget Server (for the server the window shows), Hide, Hide Others and Quit; File has Add Server… ⌘N, which shows the connection screen; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S, Previous Server ⌘[, Next Server ⌘] and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items and the window. The Go commands act on the server the window shows (its `ServerFocus`, a focused scene value holding its store and `Overlays`), and are disabled while that server's alert or modal is open (`dialogOpen`); the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. Previous and Next Server switch the window to the saved server before or after the current one, in saved order, wrapping round; from the connection screen they go to the first. The Ctrl variants (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay.
The app menu has About, Check for Updates…, Forget Server (for the server the window shows), Hide, Hide Others and Quit; File has New Task ⌘N, which creates an untitled task in the right pane's project and opens it with its composer focused, and Add Server…, which shows the connection screen; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S, Previous Server ⌘[, Next Server ⌘] and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items and the window. New Task and the Go commands act on the server the window shows (its `ServerFocus`, a focused scene value holding its store and `Overlays`), and are disabled while that server's alert or modal is open (`dialogOpen`); the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. Previous and Next Server switch the window to the saved server before or after the current one, in saved order, wrapping round; from the connection screen they go to the first. The Ctrl variants (Ctrl-K, N, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay.
## Connection
+4 -2
View File
@@ -79,6 +79,8 @@ The UI's labels (see "Task state" under the task switcher) read `state` for **Wo
`POST /api/tasks/{id}/merge-in` (the transcript's **Sync from main** button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends `mergeInPrompt` (`internal/agents/mergein.go`) as a `byre` message to the task's session, or starts a session with it when the task has none. The prompt has the agent run `git merge --no-ff --no-commit`, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. If resolving the conflicts took substantive changes rather than combining both sides as written, the prompt tells the agent to stop there and end its turn describing them; the turn's snapshot then gets its review like any other (see "Reviews"). byre doesn't inspect the merge itself: whether a change is substantive, and whether to stop, is the agent's call. On a Merge turn the prompt adds that byre squashes and pushes when the turn ends, and tells the agent to call the `stop_merge` MCP tool first if it stops (see below). The prompt ends by showing the task's current merge title and asking the agent to set a better one with `set_merge_title` if it no longer describes all the work on the branch. It is refused (409) while a session is `running`.
A task's `title` is optional at creation (`POST /api/projects/{id}/tasks` without `title` stores it empty). Task agents name and rename their task with the `set_task_title` MCP tool (`internal/agents/mergein.go`): one non-empty line, stored in `tasks.title` by `Store.SetTitle` and recorded as a `task.title` event that the transcript shows as a note and that makes the app reload the project's tasks. It is refused once the task is closed and while `merging_by`, `finalizing` or `merge_queued_by` is set, since a merge title that follows the task title must not change under a merge. The guidance (`taskGuidance` in `internal/agents/guidance.go`) tells the agent to call it first thing with a very short, distinct, to-the-point title when the task has none, or when its title isn't one, and again whenever the work moves on.
A task's `merge_title` is the title of the squash commit Merge makes. It is the task title until the agent calls the `set_merge_title` MCP tool (`internal/agents/mergein.go`), which it may do until a merge is confirmed: one line of at most 72 characters (`maxMergeTitle`, checked by `cleanMergeTitle`), stored in `tasks.merge_title` (empty there means "use the title"), recorded as a `task.merge_title` event that the transcript shows as a note. Task titles have no length limit (a TODO item becomes the title whole), so when an implementer session's agent process starts with a merge title over 72 characters, `longTitleGuidance` (`internal/agents/guidance.go`) adds a line to its guidance quoting the title and its length and telling the agent to call `set_merge_title` early in its first turn; a process started once the title fits gets no such line. Malformed titles are rejected with a corrective message, and so is a call once the task is closed or while `merging_by`, `finalizing` or `merge_queued_by` is set. The Merge confirm dialog shows the title in a text field the human can edit (Enter confirms), lists the co-authors, and sends the field's value as `merge_title` in the body of `POST /api/tasks/{id}/merge`. Merge validates it like the tool does (400 when malformed) and, when it differs from the stored title, stores it with the human as the event's actor before starting or holding the merge.
A task's participants are the users who put a message bubble with their name in its transcript, derived from its events (`Store.Participants`): the actors of its `user.message` events, joined to `users` so `byre` and `agent` drop out. That covers messages sent to the agent and inbox answers, whose delivery message carries the name of the first responder in it (`Deliver` in `internal/agents/inbox.go`); dismissing an item puts nothing in the transcript and doesn't count. A fork's participants also include the user who forked it, the actor of its `task.created` event (whose payload carries `forked_from`), whose "forked this task" note opens its transcript. They are ordered by their first such event. `GET /api/tasks/{id}/co-authors` returns the participants other than the caller, with their git name and email from `PUT /api/me` (empty for a user who hasn't set one). The Merge confirm fetches it and lists `Name <email>` for each co-author, and separately those with no git identity, who get no trailer.
@@ -278,7 +280,7 @@ Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and
**Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other text) are kept in memory by `Drafts` (`mac/Sources/Byre/Drafts.swift`) and saved to a SwiftData `DraftRecord` per server and login (`docs/macos.md`, "Drafts"), one JSON object with `composer` (keyed by `message:<task id>` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id), and `comments`, the Changes view's pending line comments keyed by task id, each `{anchor: {path, line, old, quote}, text}`. A comment shows only under a diff line whose path, line number, side and text all still match, so after the agent moves lines a comment never attaches to a different line. Every pending comment not shown under a line (its file closed or not loaded, or its line gone from the diff) is listed at the top of the Changes view with its `path:line`, the quoted line, its editable text and **remove**, so nothing is sent unseen. Writes are debounced (400 ms) and flushed at once when a composer or answer box loses focus, on send and on quit. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a relaunch mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. The composer follows its `Drafts` text as it changes, so text put back from outside it (a withdrawn queued message, a draft synced in from another Mac) shows at once. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A record that doesn't decode is logged and ignored, as is a write that fails; drafts then live only in memory. Pending images are not stored, only their count: after a relaunch the composer shows how many need re-attaching, until dismissed or the next successful send.
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").
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. With nothing typed, a last "New task in …" row creates an untitled task with no session in the right pane's project (the first project when there is none) and opens it with its composer focused; it shows the user's default model (see "Agent sessions"), the model the composer offers first for the new task's first message. ⌘N (File ▸ New Task) does the same without the switcher. Typing filters tasks and projects and replaces that row with a "Start in …" row, which creates an untitled task and starts its agent on the default model with the typed text as its first message, through `POST /api/projects/{id}/tasks` with `prompt` set to the text and no `title`; 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 the create rows for each of those projects with "fix" as the message. When no project name starts with it, the whole text is the message, so "Fix: login" stays as typed. The app shows an untitled task as "New task" until its agent names it. 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 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.
@@ -477,7 +479,7 @@ A workflow is a named graph of stages, each a stage name, the stages it comes af
- **Validation.** `workflow.Validate` refuses a definition (400, everything wrong listed) with no stages, a stage name that isn't a valid one-line name or repeats another stage's, an empty model without `agents` or a model with them, `agents` that fail `workflow.TeamProblems`, an empty prompt, a `base` other than `parent` or `main`, a `base` on a stage with more than one `after` stage, an `after` naming the stage itself, a name twice or a name that isn't a stage, a cycle, or anything but exactly one final stage (one no other stage comes after). A stage's vendor follows from its model (`workflow.VendorOf`, the same rule sessions use: `gpt-*` is Codex, `gemini-*` Antigravity, everything else Claude Code); a file with a `vendor` key is refused as an unknown field.
- **API.** `GET /api/workflows` lists every workflow's name and description. `GET /api/workflows/{name}` downloads its file; `PUT` takes a file as the body and creates or replaces the workflow; `DELETE` removes it. Each save or delete records a `workflow.updated` or `workflow.deleted` event carrying the name. `sqlite3 byre.db 'SELECT name FROM workflows'` lists them. Saves and reads go through the Manager (`internal/agents/workflows.go`) so the HTTP API and the lead's tools share them.
- **Lead tools.** `list_workflows`, `read_workflow` (`name`, or `task_id` for a task's frozen copy), `save_workflow` (`name` and `definition`, the file as YAML) and `delete_workflow`, with the same checks and errors as the API. Task agents don't get them.
- **Starting.** The lead's `create_task` and `start_todo` take an optional `workflow` (404 when there is none of that name) and then start a run of it instead of an agent on the prompt (`Manager.StartWorkflow`); `model` together with `workflow` is refused, since each stage names its own. `POST /api/projects/{id}/tasks` takes the same optional `workflow`, with the title as the run's brief, and returns the run's first stage task. The task switcher (`mac/Sources/Byre/Switcher.swift`) offers it as a "Start workflow…" row after each "New task in …" and "Start in …" pair: picking that lists `GET /api/workflows`, filtered by what is typed next (Esc goes back to the task list), and picking a workflow starts the run with the typed title and opens its first stage task. A run gets a new id and creates a task for every stage with no `after` stages, branched from the default branch, or for a stage with `base: parent`, from the lead's `from_task_id` HEAD when given. Each stage task copies the workflow's name and definition as they are when the run starts into `tasks.workflow_name` and `tasks.workflow_definition`, so later saves or deletes don't change it; its brief into `tasks.workflow_brief` (the lead's `create_task` `prompt`, optional with a workflow and may be empty, or for `start_todo` the TODO item as `todoPrompt` words it); the run's title into `tasks.workflow_title`; the run id into `tasks.workflow_run`; and its stage's name into `tasks.workflow_stage`. Stage tasks are titled `<title>: <stage>`, with the run's title as their merge title, and carry the run's TODO item. The Task's `workflow`, `workflow_run` and `workflow_stage` are those; `GET /api/tasks/{id}/workflow` returns the copy as `{name, definition}` with the definition as YAML (404 for a task without one). A task agent's `create_subtask`, `POST /api/projects/{id}/tasks` without `workflow` and the Project tab's Start create tasks without one. A fork of a stage task copies its frozen workflow, brief, title, run and stage, so it is another attempt at the same stage of the same run. `sqlite3 byre.db "SELECT id, status, workflow_stage, stage_output != '' FROM tasks WHERE workflow_run = '<run>'"` lists a run's tasks and which have finished.
- **Starting.** The lead's `create_task` and `start_todo` take an optional `workflow` (404 when there is none of that name) and then start a run of it instead of an agent on the prompt (`Manager.StartWorkflow`); `model` together with `workflow` is refused, since each stage names its own. `POST /api/projects/{id}/tasks` takes the same optional `workflow`, with the title, required then, as the run's brief, and returns the run's first stage task. The task switcher (`mac/Sources/Byre/Switcher.swift`) offers it as a "Start workflow…" row after each "Start in …" row: picking that lists `GET /api/workflows`, filtered by what is typed next (Esc goes back to the task list), and picking a workflow starts the run with the typed text as its title and brief and opens its first stage task. A run gets a new id and creates a task for every stage with no `after` stages, branched from the default branch, or for a stage with `base: parent`, from the lead's `from_task_id` HEAD when given. Each stage task copies the workflow's name and definition as they are when the run starts into `tasks.workflow_name` and `tasks.workflow_definition`, so later saves or deletes don't change it; its brief into `tasks.workflow_brief` (the lead's `create_task` `prompt`, optional with a workflow and may be empty, or for `start_todo` the TODO item as `todoPrompt` words it); the run's title into `tasks.workflow_title`; the run id into `tasks.workflow_run`; and its stage's name into `tasks.workflow_stage`. Stage tasks are titled `<title>: <stage>`, with the run's title as their merge title, and carry the run's TODO item. The Task's `workflow`, `workflow_run` and `workflow_stage` are those; `GET /api/tasks/{id}/workflow` returns the copy as `{name, definition}` with the definition as YAML (404 for a task without one). A task agent's `create_subtask`, `POST /api/projects/{id}/tasks` without `workflow` and the Project tab's Start create tasks without one. A fork of a stage task copies its frozen workflow, brief, title, run and stage, so it is another attempt at the same stage of the same run. `sqlite3 byre.db "SELECT id, status, workflow_stage, stage_output != '' FROM tasks WHERE workflow_run = '<run>'"` lists a run's tasks and which have finished.
- **Stages.** A stage task's agent is an implementer session on the stage's model (for a team stage, one per agent, see "Teams"), created by the user who started the run; any later session of the task (a Compact handoff's, one started by a message) runs the same stage, since the stage belongs to the task. Starting a stage records a `task.workflow_stage` event on its task (`workflow`, `run`, `stage` the name, `after` the ids of the tasks whose outputs it gets; actor the lead for the stages a run starts with, `byre` after) and sends the session its first message, actor `byre`, so the transcript shows it collapsed under its first line: `Workflow "<name>", stage "<stage>"`, then the stage's prompt, a `## Brief` section when the brief isn't empty, a `## From stage "<stage>" (task <id>, branch <branch>)` section with each `after` stage's output, and a line saying where the task starts: from the default branch, with the branches above to `git merge` or read as it needs, or from the commit its one `after` stage finished at. Nothing else of earlier stages is passed on; their branches carry their work.
- **Advancing.** A stage session gets the `finish_stage` tool (`output`, required) and a guidance line naming the workflow, its stage and the stages after it. The call stores the output in `agent_sessions.stage_output`, replacing any earlier one; it is refused once the task's stage has finished. When a turn of that session ends normally (not an error or interrupt, not a handoff, not a Merge turn that squashes) with an output stored, byre sets `agent_sessions.stage_finished` before marking the session idle, and then advances (`nextStage`): it records the output and the worktree's HEAD in `tasks.stage_output` and `tasks.stage_commit`, then, holding `workflowMu` so two stages finishing at once can't both start the one after them, goes through each stage after this one. A stage starts once every stage it comes after has a finished task in the run (the latest one, when a fork made several); its task branches from that task's `stage_commit` for `base: parent` with one `after` stage, else from the default branch. Once every stage after it has been dealt with, byre clears `stage_output` and `stage_finished` on the session. The session stays idle in its task for more messages, and queued messages and held answers are delivered to it once advancing is done. When the final stage finishes, byre starts discarding every other active task of the run (as `byre`, through the same `Discard` as the button, so each gets its summary turn first and closes when that turn ends; one already merging or being discarded is skipped and logged as `stage task not discarded`), then records `task.workflow_finished` (`workflow`, `output`) on the final task and clears the flags in the same transaction, leaving the final task for the human to merge. A turn that ends without the call leaves the task in its stage. The output belongs to the turn that stored it: when that turn ends any other way (an error, an interrupt, a handoff, a Merge turn that squashes, an agent process that exits mid-turn, or a session that can't be resumed after a restart), byre clears `stage_output` before marking the session idle, so a later turn can't hand off a stale output; the agent calls `finish_stage` again. A turn cut off by a byre restart and resumed keeps it. If a stage can't start, byre logs `advance workflow stage` and records the error in the finishing task's transcript, and the flag stays set. On startup, before resuming running sessions, `recoverStages` advances every session with `stage_finished` set, skipping the steps already done: the recorded output isn't overwritten; a stage that already has a task in the run isn't created again, but when that task is active and has no session, its sessions are started, and each of its sessions that never got a message (`SessionStarted`) is sent the stage's first message (a team member's with its own prompt), and the restart notice isn't sent to them. `sqlite3 byre.db 'SELECT id, task_id FROM agent_sessions WHERE stage_finished = 1'` lists stages waiting to advance. `list_tasks` shows each stage task's workflow and `stage "<name>"`, with `(finished)` once its stage has finished and `workflow finished` once any task of the run has a `task.workflow_finished` event. Nothing reads or writes `agent_sessions.workflow_stage`; it stays in the schema so builds from older branches keep working against the same database.
- **Progress.** `GET /api/tasks/{id}/workflow/run` (`Manager.WorkflowRun`) returns the run of a stage task (404 for a task without one): the definition that task froze (typed, not YAML), name, title, brief, `finished` (any task of the run has `task.workflow_finished`), and per stage in definition order its resolved `after`, `waiting_on` (the `after` stages with no finished attempt), its `attempts` and a `state`. An attempt is every task of the run at that stage, closed ones included, oldest first (the first and its forks), with its `output` and `commit` once `tasks.stage_output` is set, `forked_from`, `discarded_by` (the `task.discarded` actor) and `inputs`: the tasks named in the `after` of its latest `task.workflow_stage` event, each with its stage, so it says which attempt fed it; a fork has no such event and takes its source's. An attempt's state is `merged`, `cleaned_up` (discarded after finishing; `byre` as `discarded_by` is the final stage's cleanup), `discarded` (before finishing), `finished`, `running` (a session is running) or `idle`. A stage is `waiting` with no attempts, else it takes the state of its latest finished attempt, or its latest attempt when none finished. The Workflow tab (`mac/Sources/Byre/Workflow.swift`) draws the graph natively with the flowchart layout from `Diagram.swift`, each stage's node coloured by its state (`stateColors`, from the palette's tint and accent pairs: waiting grey, running accent, idle warn, finished, merged and cleaned up ok, discarded danger) and redrawn whenever a refetch changes a state; then a card per stage with its state, its attempts linking to their tasks, each attempt's inputs ("started from") and the attempts it fed, and its output collapsed as Markdown. It refetches on any stored `task.*` or `session.*` event of a task in the run, and on a `task.created` whose payload carries the run, so a new stage or fork shows up.