Signal the lead when a task is created, except tasks it created

This commit is contained in:
Ian Gulliver
2026-10-15 10:12:44 -07:00
parent fdd005b591
commit 184bfe429e
15 changed files with 112 additions and 29 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie
- **Sessions.** A lead session is an `agent_sessions` row with `owner` set to the user's login, `role` `lead` and no `task_id`. Its events carry `owner` and no project or task. It runs in `leads/<login>` with `leadGuidance` (passed and re-attached the same way as a task agent's guidance) and the lead tools instead of the task tools. It has no Stop-hook bounce, no snapshots, no end-of-turn commit and no inbox report; a turn that ends in error records the error on its `session.state` event, which the pane shows as a note. Its turns don't count against the caps.
- **Messages.** `POST /api/me/lead/messages` sends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held in `lead_inputs` and becomes its next turn; the composer stays enabled. `GET /api/me/lead/sessions` lists the caller's lead sessions; interrupt and compact use the usual session endpoints.
- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation, a report's text cut to 1500 characters, an error or rate limit), `inbox.updated` when a human answered a question or error, `task.merged` and `task.discarded`. The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)". Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `web/src/task.ts`) never draws an `agent.message` that is exactly `NO_REPLY`, hides the live partial message while its text is still a prefix of it, and when that turn ends folds it into one collapsed row, "byre <the turn's first line> · nothing to say", that expands to the signals and anything the lead did.
- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation, a report's text cut to 1500 characters, an error or rate limit), `inbox.updated` when a human answered a question or error, `task.created` (naming the TODO item it was started from, if any), `task.merged` and `task.discarded`. The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor `<login> (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)". Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `web/src/task.ts`) never draws an `agent.message` that is exactly `NO_REPLY`, hides the live partial message while its text is still a prefix of it, and when that turn ends folds it into one collapsed row, "byre <the turn's first line> · nothing to say", that expands to the signals and anything the lead did.
- **Tools.** `list_tasks`, `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`), `read_inbox`, `answer_question` (an `option`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; refused while it is mid-turn), `create_task`, `start_todo`, `interrupt`, `compact_task`, `merge_task`, `discard_task`, `show` and `compact_self`. The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor `<login> (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks.
- **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id`, `tabs` (the tabs put first, in order) and `tab` (the selected one). With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Todo tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The page applies the focus on load and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the navbar crumb, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. The URL follows the focus: `#/task/<id>`, `#/project/<id>`, or `#/` for neither.
- **Pane lock.** While the human is working in the right pane, the lead's `show` is refused with an error telling it to say in chat what it would have shown. The page sends `PUT /api/me/pane-focus` with `focused` whenever that changes: true while the browser window has focus and the focused element is inside the right pane. The pane itself is focusable (`tabIndex` -1), so a click anywhere in it counts, not only on inputs and buttons; a re-render that removes the focused element hands focus back to the pane rather than counting as leaving. The page sends false on `pagehide` (with `keepalive`) and resends true when its event stream reconnects. byre keeps the flag in memory per user (`Manager.paneFocused`), so it starts false after a restart until the page resends it.
+1 -1
View File
@@ -84,7 +84,7 @@ func TestCancelQuestion(t *testing.T) {
if err != nil {
t.Fatalf("create project: %v", err)
}
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "Fix the thing", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now})
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "Fix the thing", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
if err != nil {
t.Fatalf("create task: %v", err)
}
+14 -3
View File
@@ -15,8 +15,8 @@ import (
)
const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's web UI. 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, turn reports and errors that task agents file, questions the human answered, tasks merged or discarded. Several may arrive in one message. 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 merged 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.
- Signals: byre sends you, as messages from byre, what happens across tasks: questions, turn reports and errors that task agents file, questions the human answered, tasks created, merged or discarded. Several may arrive in one message. 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 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.
- Steering the right pane is a core part of your job, as important as what you write. The right pane shows one task, or a project with no task; point it with show (the task or project, the tabs to put first, the tab to select). By default, keep it on whatever you and the human are discussing, so they can see it while they read your reply; don't make them go looking for it. Call show in the same turn you bring something up:
- Discussing, picking or starting TODO items: the project's todo tab (show with project_id when no task of that project is involved).
- A question or report that needs the human: that task's inbox tab, and say in a sentence or two what it is and what you recommend.
@@ -251,7 +251,7 @@ func (m *Manager) leadTurnEnded(ctx context.Context, l *live, res claude.Result)
func (m *Manager) Observe(ev api.Event) {
switch ev.Kind {
case "inbox.created", "inbox.updated", "task.merged", "task.discarded":
case "inbox.created", "inbox.updated", "task.created", "task.merged", "task.discarded":
default:
return
}
@@ -325,6 +325,17 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error)
}
return m.itemText(ctx, item), nil
case "task.created":
task := api.Task{}
err := remarshal(ev.Payload, &task)
if err != nil {
return "", err
}
if task.TodoItem == nil {
return fmt.Sprintf("%s was created by %s.", m.taskLabel(ctx, task.Id), ev.Actor), nil
}
return fmt.Sprintf("%s was created by %s from TODO item %q.", m.taskLabel(ctx, task.Id), ev.Actor, *task.TodoItem), nil
case "task.merged":
commit, _ := ev.Payload["commit"].(string)
if commit == "" {
+68
View File
@@ -0,0 +1,68 @@
package agents
import (
"context"
"path/filepath"
"testing"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/store"
)
func TestTaskCreatedSignalText(t *testing.T) {
ctx := context.Background()
events := []api.Event{}
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(ev api.Event) { events = append(events, ev) })
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
now := time.Now().UTC()
_, err = st.EnsureUser(ctx, "ian@fc.run", "Ian")
if err != nil {
t.Fatalf("ensure user: %v", err)
}
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "byre", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "ian@fc.run", CreatedAt: now})
if err != nil {
t.Fatalf("create project: %v", err)
}
item := "Question answers given while the agent is working get lost"
tasks := []api.Task{
{Id: "a5ad5dbb72eb", ProjectId: "p1", Title: item, Branch: "byre/a5ad5dbb72eb", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "ian@fc.run", CreatedAt: now, TodoItem: &item},
{Id: "b6be6ecc83fc", ProjectId: "p1", Title: "Fix the thing", Branch: "byre/b6be6ecc83fc", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "ian@fc.run", CreatedAt: now},
}
for _, task := range tasks {
err = st.CreateTask(ctx, task, task.CreatedBy)
if err != nil {
t.Fatalf("create task: %v", err)
}
}
created := []api.Event{}
for _, ev := range events {
if ev.Kind == "task.created" {
created = append(created, ev)
}
}
if len(created) != 2 {
t.Fatalf("task.created events %+v", created)
}
m := New(st, DefaultLimits, t.TempDir(), t.TempDir())
want := []string{
`[byre ▸ Question answers given while the agent is working get lost, task a5ad5dbb72eb] was created by ian@fc.run from TODO item "Question answers given while the agent is working get lost".`,
`[byre ▸ Fix the thing, task b6be6ecc83fc] was created by ian@fc.run.`,
}
for i, ev := range created {
got, err := m.signalText(ctx, ev)
if err != nil {
t.Fatalf("signal text: %v", err)
}
if got != want[i] {
t.Fatalf("signal text\n got %q\nwant %q", got, want[i])
}
}
}
+2 -2
View File
@@ -195,7 +195,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
return "", err
}
}
task, err := m.CreateTask(ctx, project, args.Title, user.Login, base)
task, err := m.CreateTask(ctx, project, args.Title, user.Login, base, leadActor(user.Login))
if err != nil {
return "", err
}
@@ -227,7 +227,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
if err != nil {
return "", err
}
task, err := m.StartTodo(ctx, project, user, args.Title, m.modelOr(args.Model, l))
task, err := m.StartTodo(ctx, project, user, args.Title, m.modelOr(args.Model, l), leadActor(user.Login))
if err != nil {
return "", err
}
+8 -4
View File
@@ -97,7 +97,7 @@ func (e *env) addTask() api.Task {
if err != nil {
e.t.Fatalf("add worktree: %v", err)
}
err = e.store.CreateTask(e.ctx, task)
err = e.store.CreateTask(e.ctx, task, login)
if err != nil {
e.t.Fatalf("create task: %v", err)
}
@@ -884,12 +884,13 @@ func TestStartTodo(t *testing.T) {
gitOut(t, e.repo, "add", "TODO.md")
gitOut(t, e.repo, "commit", "--quiet", "--message", "add todo")
_, err := m.StartTodo(e.ctx, e.project, e.user, "No such item", testModel)
lead := login + " (lead)"
_, err := m.StartTodo(e.ctx, e.project, e.user, "No such item", testModel, lead)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("start unknown item: %v", err)
}
task, err := m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel)
task, err := m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel, lead)
if err != nil {
t.Fatalf("start todo: %v", err)
}
@@ -897,7 +898,7 @@ func TestStartTodo(t *testing.T) {
t.Fatalf("todo task %+v", task)
}
_, err = m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel)
_, err = m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel, lead)
if !errors.Is(err, agents.ErrConflict) {
t.Fatalf("second start of the same item: %v", err)
}
@@ -906,6 +907,9 @@ func TestStartTodo(t *testing.T) {
return ev.Kind == "turn.ended" && ev.TaskId != nil && *ev.TaskId == task.Id
})
for _, ev := range events {
if ev.Kind == "task.created" && ev.TaskId != nil && *ev.TaskId == task.Id && (ev.Actor != lead || ev.Payload["created_by"] != login) {
t.Fatalf("task.created actor %q, created_by %v", ev.Actor, ev.Payload["created_by"])
}
if ev.Kind == "user.message" && ev.TaskId != nil && *ev.TaskId == task.Id {
text, _ := ev.Payload["text"].(string)
if !strings.Contains(text, "- Create hello.txt\n It holds the single line `hi`.") || !strings.Contains(text, "remove this item from TODO.md") {
+1 -1
View File
@@ -29,7 +29,7 @@ func TestSetMergeTitle(t *testing.T) {
if err != nil {
t.Fatalf("create project: %v", err)
}
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "Fix the thing", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now})
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "Fix the thing", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
if err != nil {
t.Fatalf("create task: %v", err)
}
+1 -1
View File
@@ -30,7 +30,7 @@ func TestRateLimitedTurn(t *testing.T) {
t.Fatalf("create project: %v", err)
}
task := api.Task{Id: "t1", ProjectId: "p1", Title: "t", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}
err = st.CreateTask(ctx, task)
err = st.CreateTask(ctx, task, "alice")
if err != nil {
t.Fatalf("create task: %v", err)
}
+1 -1
View File
@@ -58,7 +58,7 @@ func TestSnapshots(t *testing.T) {
if err != nil {
t.Fatalf("add worktree: %v", err)
}
err = st.CreateTask(ctx, task)
err = st.CreateTask(ctx, task, "alice")
if err != nil {
t.Fatalf("create task: %v", err)
}
+8 -8
View File
@@ -16,11 +16,11 @@ import (
"g.fc.run/misc/byre/internal/todo"
)
func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base string) (api.Task, error) {
return m.createTask(ctx, project, title, createdBy, base, nil)
func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base, actor string) (api.Task, error) {
return m.createTask(ctx, project, title, createdBy, base, actor, nil)
}
func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator api.User, title, model string) (api.Task, error) {
func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator api.User, title, model, actor string) (api.Task, error) {
if creator.GitName == "" || creator.GitEmail == "" {
return api.Task{}, ErrNoGitIdentity
}
@@ -47,12 +47,12 @@ func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator ap
}
}
task, err := m.createTask(ctx, project, item.Title, creator.Login, project.DefaultBranch, &item.Title)
task, err := m.createTask(ctx, project, item.Title, creator.Login, project.DefaultBranch, actor, &item.Title)
if err != nil {
return api.Task{}, err
}
_, err = m.create(ctx, task, creator, todoPrompt(item), model, creator.Login, nil)
_, err = m.create(ctx, task, creator, todoPrompt(item), model, actor, nil)
if err != nil {
return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
}
@@ -64,7 +64,7 @@ func todoPrompt(item api.TodoItem) string {
todo.File, todo.Markdown(item))
}
func (m *Manager) createTask(ctx context.Context, project api.Project, title, createdBy, base string, todoItem *string) (api.Task, error) {
func (m *Manager) createTask(ctx context.Context, project api.Project, title, createdBy, base, actor string, todoItem *string) (api.Task, error) {
id := store.NewID()
t := api.Task{
Id: id,
@@ -85,7 +85,7 @@ func (m *Manager) createTask(ctx context.Context, project api.Project, title, cr
return api.Task{}, err
}
err = m.store.CreateTask(ctx, t)
err = m.store.CreateTask(ctx, t, actor)
if err != nil {
return api.Task{}, err
}
@@ -165,7 +165,7 @@ func (m *Manager) createTaskFromAgent(ctx context.Context, l *live, raw json.Raw
model = sess.Model
}
task, err := m.CreateTask(ctx, project, args.Title, creator.Login, base)
task, err := m.CreateTask(ctx, project, args.Title, creator.Login, base, creator.Login)
if err != nil {
return "", err
}
+1 -1
View File
@@ -57,7 +57,7 @@ func TestBurstIsOneNotification(t *testing.T) {
if err != nil {
t.Fatalf("create project: %v", err)
}
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "Fix login", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now})
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "Fix login", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
if err != nil {
t.Fatalf("create task: %v", err)
}
+2 -2
View File
@@ -266,7 +266,7 @@ func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID st
return
}
t, err := s.agents.CreateTask(r.Context(), p, req.Title, actor(r), p.DefaultBranch)
t, err := s.agents.CreateTask(r.Context(), p, req.Title, actor(r), p.DefaultBranch, actor(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
@@ -317,7 +317,7 @@ func (s *Server) StartTodoItem(w http.ResponseWriter, r *http.Request, projectID
model = *req.Model
}
t, err := s.agents.StartTodo(r.Context(), p, u, req.Title, model)
t, err := s.agents.StartTodo(r.Context(), p, u, req.Title, model, u.Login)
if err != nil {
writeAgentError(w, err)
return
+1 -1
View File
@@ -28,7 +28,7 @@ func TestQueuedTurnKeepsImages(t *testing.T) {
if err != nil {
t.Fatalf("create project: %v", err)
}
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "t1", Branch: "t1", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now})
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "t1", Branch: "t1", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
if err != nil {
t.Fatalf("create task: %v", err)
}
+1 -1
View File
@@ -29,7 +29,7 @@ func TestClosingTaskDismissesOpenInbox(t *testing.T) {
t.Fatalf("create project: %v", err)
}
for _, id := range []string{"t1", "t2"} {
err = st.CreateTask(ctx, api.Task{Id: id, ProjectId: "p1", Title: id, Branch: id, WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now})
err = st.CreateTask(ctx, api.Task{Id: id, ProjectId: "p1", Title: id, Branch: id, WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
if err != nil {
t.Fatalf("create task: %v", err)
}
+2 -2
View File
@@ -506,7 +506,7 @@ func (s *Store) GetProject(ctx context.Context, id string) (api.Project, error)
return p, err
}
func (s *Store) CreateTask(ctx context.Context, t api.Task) error {
func (s *Store) CreateTask(ctx context.Context, t api.Task, actor string) error {
todoItem := ""
if t.TodoItem != nil {
todoItem = *t.TodoItem
@@ -519,7 +519,7 @@ func (s *Store) CreateTask(ctx context.Context, t api.Task) error {
if err != nil {
return err
}
return emit(api.Event{Kind: "task.created", ProjectId: &t.ProjectId, TaskId: &t.Id, Actor: t.CreatedBy, Payload: ToPayload(t)})
return emit(api.Event{Kind: "task.created", ProjectId: &t.ProjectId, TaskId: &t.Id, Actor: actor, Payload: ToPayload(t)})
})
}