Add lead wake_on tool to wake the lead on a task's merge or discard

This commit is contained in:
Ian Gulliver
2026-10-21 12:10:25 -07:00
parent 9ab1c0d079
commit 8c16bc3fb0
4 changed files with 101 additions and 4 deletions
+35 -3
View File
@@ -15,7 +15,7 @@ import (
)
const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's app. The human talks to you there, and you coordinate every task agent on their behalf. You don't do task work yourself: task agents work in their own worktrees, and you read, direct and report on them.
- Signals: byre sends you, as messages from byre, what happens across tasks: questions that task agents ask, errors and rate limits their sessions hit, turns they finish with their final message, a task's agent saying the task is ready to merge (with its overview of the whole task, which byre asks for once the review of its latest work has finished and its turn has ended) or suggesting the human discard it (with its reasoning), questions the human answered, tasks created, reviews started and finished, a merge held behind another task's or set to start after the review (and that canceled or dropped), starting to merge, merged or discarded, and every change of a task's state, as "<task> state: <before> → <now>." listed first among what happened at the same moment. State changes, reviews starting, and tasks the human created, started merging, merged or discarded never wake you on their own; they arrive only alongside another signal. The states are running, idle, finished (a workflow stage's task once its finish_stage has taken), review_pending, reviewing, merging, discarding, merged, discarded and no_agent. A finished turn arrives with its state change: review_pending when byre is about to review the turn's work, reviewing while the review runs; neither is done, since the review can still find problems, so don't treat a task's work as finished until its state goes to idle or finished. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. Messages from the human arrive separately.
- Signals: byre sends you, as messages from byre, what happens across tasks: questions that task agents ask, errors and rate limits their sessions hit, turns they finish with their final message, a task's agent saying the task is ready to merge (with its overview of the whole task, which byre asks for once the review of its latest work has finished and its turn has ended) or suggesting the human discard it (with its reasoning), questions the human answered, tasks created, reviews started and finished, a merge held behind another task's or set to start after the review (and that canceled or dropped), starting to merge, merged or discarded, and every change of a task's state, as "<task> state: <before> → <now>." listed first among what happened at the same moment. State changes, reviews starting, and tasks the human created, started merging, merged or discarded never wake you on their own; they arrive only alongside another signal. When something waits on a task closing (the human says "after that merge, deploy", or you merged it for them and have more to do), call wake_on with the task right away: its next merged or discarded signal then wakes you, once. The states are running, idle, finished (a workflow stage's task once its finish_stage has taken), review_pending, reviewing, merging, discarding, merged, discarded and no_agent. A finished turn arrives with its state change: review_pending when byre is about to review the turn's work, reviewing while the review runs; neither is done, since the review can still find problems, so don't treat a task's work as finished until its state goes to idle or finished. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. Messages from the human arrive separately.
- Don't respond to what the human just did. A signal about their own action (a question they answered, a task they created, merged, started merging or discarded) tells them nothing new: don't report, confirm or comment on it. Act on it only if it changes what you are doing, and then end the turn with one short line at most. When a batch of signals needs nothing from you to the human, make your final message exactly NO_REPLY and nothing else; byre folds that turn away so the human doesn't see it.
- "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (questions, then errors on the task's transcript tab, then finished turns, then tasks ready to merge or suggested for discarding), with one line saying what it is. If nothing needs them, show that project's project tab, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, for example [Fix login redirect](#/todo/prj3f2a9C1b7dQ/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY.
- Whenever you suggest new work that isn't in TODO.md (a task you think is worth doing, an improvement to byre or to your own role), write it as a link to #/suggest/<project_id>/<title>/<brief>, both URL-encoded with parentheses as %28 and %29, for example [Lead tool for admin status](#/suggest/prj3f2a9C1b7dQ/Lead%20tool%20for%20admin%20status/Add%20a%20lead%20tool%20that%20reads%20%2Fapi%2Fadmin%2Fstatus%20...). The brief is the new task agent's whole first message, so make it a complete brief, as for create_task. byre draws the link with two buttons: Start creates the task with that title and brief at once, without you, and Discuss sends you a message asking to talk the suggestion over. Once an active task has that title, it shows the task's state instead. Don't ask whether to start a suggestion; the buttons do that.
@@ -424,6 +424,10 @@ func (m *Manager) signal(ctx context.Context, batch []api.Event) error {
if err != nil {
return err
}
woken, err := m.takeWakes(ctx, batch)
if err != nil {
return err
}
batch, err = m.dropBackground(ctx, batch)
if err != nil || len(batch) == 0 {
return err
@@ -443,7 +447,8 @@ func (m *Manager) signal(ctx context.Context, batch []api.Event) error {
lines := []string{}
loud := false
for i, ev := range batch {
if ev.Actor == leadActor(owner) {
wake := closeSignal(ev) && woken[deref(ev.TaskId)][owner]
if ev.Actor == leadActor(owner) && !wake {
continue
}
freed, err := m.freedText(ctx, owner, ev)
@@ -454,7 +459,10 @@ func (m *Manager) signal(ctx context.Context, batch []api.Event) error {
if text == "" {
continue
}
if freed != "" || !quietSignal(ev) {
if wake {
text += "\nYou asked to be woken by this with wake_on."
}
if wake || freed != "" || !quietSignal(ev) {
loud = true
}
if ev.Kind == "task.state" {
@@ -476,6 +484,30 @@ func (m *Manager) signal(ctx context.Context, batch []api.Event) error {
return nil
}
func closeSignal(ev api.Event) bool {
return (ev.Kind == "task.merged" || ev.Kind == "task.discarded") && ev.TaskId != nil
}
func (m *Manager) takeWakes(ctx context.Context, batch []api.Event) (map[string]map[string]bool, error) {
woken := map[string]map[string]bool{}
for _, ev := range batch {
if !closeSignal(ev) {
continue
}
owners, err := m.store.TakeLeadWakes(ctx, *ev.TaskId)
if err != nil {
return nil, err
}
for _, owner := range owners {
if woken[*ev.TaskId] == nil {
woken[*ev.TaskId] = map[string]bool{}
}
woken[*ev.TaskId][owner] = true
}
}
return woken, nil
}
func (m *Manager) dropBackground(ctx context.Context, batch []api.Event) ([]api.Event, error) {
kept := []api.Event{}
for _, ev := range batch {
+24 -1
View File
@@ -408,7 +408,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
"Merge a task as the human: its agent merges the default branch in, then byre squashes it into one commit, pushes it to origin and closes the task. "+
"While another task in the project is merging, byre holds the merge and starts it when that one is done. "+
"For a subtask, target parent instead asks its parent task's agent to merge it into the parent's branch, and the subtask closes once the parent has. "+
"Refused for a task with active subtasks. Only when the human asks. You get a signal when it is merged; failures show as errors in the task's transcript, and you get a signal for each."+stoppedRefusal,
"Refused for a task with active subtasks. Only when the human asks. Its merged signal arrives only alongside another signal unless you call wake_on for the task; failures show as errors in the task's transcript, and you get a signal for each."+stoppedRefusal,
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"target": {"type": "string", "enum": ["main", "parent"], "description": "Default main."}
@@ -489,6 +489,29 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
return fmt.Sprintf("Discarding %s: its agent gets a turn to write the task summary, and byre discards the task when that turn ends.", task.Id), nil
}),
tool("wake_on",
"Make an active task's next merged or discarded signal wake you, once, whoever merged or discarded it. "+
"Without it, a merge or discard by the human only arrives alongside another signal. Use it when something waits on that task closing, "+
"for example \"after that merge, deploy\".",
taskOnly,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
task, _, err := m.taskArg(ctx, raw)
if err != nil {
return "", err
}
if task.Background {
return "", fmt.Errorf("%w: %s is a background task, whose signals never reach you", ErrInvalid, task.Id)
}
err = m.store.AddLeadWake(ctx, user.Login, task.Id)
if errors.Is(err, store.ErrNotFound) {
return "", fmt.Errorf("%w: %s has already been merged or discarded", ErrInvalid, task.Id)
}
if err != nil {
return "", err
}
return fmt.Sprintf("You'll be woken when %s is merged or discarded.", task.Id), nil
}),
tool("show",
"Point the human's right pane at a task, or at a project with no task, and select one of its tabs. The tabs' order is fixed. "+
"Whenever the human asks you to show something (\"show me that when you can\") or you want to show something, call show right away; never hold back because you expect a refusal, since only a refused show arranges byre's signal to try again. "+
+36
View File
@@ -101,6 +101,42 @@ func (s *Store) DeleteLeadInputs(ctx context.Context, ids []int64) error {
})
}
func (s *Store) AddLeadWake(ctx context.Context, owner, taskID string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
active := false
err := tx.QueryRowContext(ctx, `SELECT status = ? FROM tasks WHERE id = ?`, string(api.TaskStatusActive), taskID).Scan(&active)
if errors.Is(err, sql.ErrNoRows) || (err == nil && !active) {
return ErrNotFound
}
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO lead_wakes (owner, task_id, created_at) VALUES (?, ?, ?)`, owner, taskID, now())
return err
})
}
func (s *Store) TakeLeadWakes(ctx context.Context, taskID string) ([]string, error) {
owners := []string{}
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
rows, err := tx.QueryContext(ctx, `DELETE FROM lead_wakes WHERE task_id = ? RETURNING owner`, taskID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
owner := ""
err = rows.Scan(&owner)
if err != nil {
return err
}
owners = append(owners, owner)
}
return rows.Err()
})
return owners, err
}
func (s *Store) GetFocus(ctx context.Context, login string) (api.Focus, error) {
f := api.Focus{}
raw := ""
@@ -0,0 +1,6 @@
CREATE TABLE lead_wakes (
owner TEXT NOT NULL REFERENCES users(login),
task_id TEXT NOT NULL REFERENCES tasks(id),
created_at TEXT NOT NULL,
PRIMARY KEY (owner, task_id)
);