Signal the lead when a task is created, except tasks it created
This commit is contained in:
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user