Let task agents create and manage subtasks

This commit is contained in:
Ian Gulliver
2026-10-16 10:36:38 -07:00
parent fd63a0dfbc
commit 0f2415570c
30 changed files with 1067 additions and 287 deletions
+1
View File
@@ -52,6 +52,7 @@ func (m *Manager) Fork(ctx context.Context, src api.Task, project api.Project, n
ForkedAt: &n,
WorkflowRun: src.WorkflowRun,
WorkflowStage: src.WorkflowStage,
ParentTaskId: src.ParentTaskId,
}, frozen, snap.CommitSha, creator.Login)
if err != nil {
return api.Task{}, err
+13
View File
@@ -75,6 +75,13 @@ func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, erro
g += fmt.Sprintf("\n- This task was started from the item %q in %s at the repo root. When the work is done, remove that item from %s in this worktree, so the removal lands in the same merge as the work.",
*task.TodoItem, todo.File, todo.File)
}
if task.ParentTaskId != nil {
parent, err := m.store.GetTask(ctx, *task.ParentTaskId)
if err != nil {
return "", err
}
g += subtaskGuidance(parent)
}
if task.ForkedFrom == nil {
return g, nil
}
@@ -85,6 +92,12 @@ func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, erro
return g + forkGuidance(task, src), nil
}
func subtaskGuidance(parent api.Task) string {
return fmt.Sprintf("\n- This task is a subtask of byre task %q (%s), whose agent created it and manages it. Your final message at the end of each turn is sent to that agent, which may answer with more instructions. "+
"Your work reaches %s's branch or the default branch when the subtask is merged.",
parent.Title, parent.Id, parent.Id)
}
func forkGuidance(task, src api.Task) string {
return fmt.Sprintf("\n- This conversation was forked from byre task %q at its snapshot %d, into a task of its own: branch %s, worktree %s, which held that snapshot's files when the fork was made. "+
"Everything before the fork happened in %s, which belongs to the other task: from now on read and change files only in %s, never in the other worktree, and use paths under it.",
+6
View File
@@ -455,6 +455,9 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error)
if task.ForkedFrom != nil {
return fmt.Sprintf("%s was forked by %s from %s at snapshot %d.", m.taskLabel(ctx, task.Id), ev.Actor, m.taskLabel(ctx, *task.ForkedFrom), *task.ForkedAt), nil
}
if task.ParentTaskId != nil {
return fmt.Sprintf("%s was created as a subtask of %s by its agent.", m.taskLabel(ctx, task.Id), m.taskLabel(ctx, *task.ParentTaskId)), nil
}
if task.TodoItem == nil {
return fmt.Sprintf("%s was created by %s.", m.taskLabel(ctx, task.Id), ev.Actor), nil
}
@@ -468,6 +471,9 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error)
if commit == "" {
return fmt.Sprintf("%s was merged by %s with nothing to commit.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor), nil
}
if into, ok := ev.Payload["parent_task_id"].(string); ok {
return fmt.Sprintf("%s was merged by %s into %s, now at %s.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor, m.taskLabel(ctx, into), short(commit)), nil
}
return fmt.Sprintf("%s was merged by %s as %s and pushed to %v.", m.taskLabel(ctx, deref(ev.TaskId)), ev.Actor, short(commit), ev.Payload["pushed"]), nil
case "task.discarded":
+31 -3
View File
@@ -302,13 +302,35 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
tool("merge_task",
"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. "+
"Only when the human asks. You get a signal when it is merged; failures are filed in the task's inbox.",
taskOnly,
"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 are filed in the task's inbox.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"target": {"type": "string", "enum": ["main", "parent"], "description": "Default main."}
}, "required": ["task_id"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
Target string `json:"target"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
task, project, err := m.taskArg(ctx, raw)
if err != nil {
return "", err
}
switch args.Target {
case "", "main":
case "parent":
_, err = m.MergeIntoParent(ctx, task, user)
if err != nil {
return "", err
}
return fmt.Sprintf("Merge of %s into its parent %s requested.", task.Id, *task.ParentTaskId), nil
default:
return "", fmt.Errorf("%w: target must be main or parent, not %q", ErrInvalid, args.Target)
}
task, err = m.Merge(ctx, task, project, user, task.MergeTitle)
if err != nil {
return "", err
@@ -320,7 +342,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
}),
tool("discard_task",
"Discard a task: its agent gets one turn to write the task summary, then byre stops it and deletes its worktree and branch without merging. This destroys its unmerged work. Only when the human asks. "+
"Discard a task: its agent gets one turn to write the task summary, then byre stops it and deletes its worktree and branch without merging. This destroys its unmerged work. Refused for a task with active subtasks. Only when the human asks. "+
"If the agent is mid-turn, the summary turn starts when that turn ends; no other turn runs in between.",
taskOnly,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
@@ -712,6 +734,12 @@ func (m *Manager) listTasksText(ctx context.Context) (string, error) {
if t.ForkedFrom != nil {
fmt.Fprintf(b, "; forked from %s at snapshot %d", *t.ForkedFrom, *t.ForkedAt)
}
if t.ParentTaskId != nil {
fmt.Fprintf(b, "; subtask of %s", *t.ParentTaskId)
if t.MergingIntoParent {
b.WriteString(", merging into it")
}
}
if t.Workflow != nil {
stage, err := m.workflowStatus(ctx, t)
if err != nil {
+5 -1
View File
@@ -568,7 +568,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
if err != nil {
return nil, err
}
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l)}
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l)}, m.subtaskTools(l)...)
if sess.Role == api.Implementer && task.WorkflowStage != nil {
frozen, err := m.store.TaskWorkflow(ctx, task.Id)
if err != nil {
@@ -848,6 +848,10 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
if err != nil {
return err
}
go m.closeMergedSubtasks(l.taskID, l.dir)
if payload["summary"] == true && discard.State == store.DiscardNone {
go m.reportToParent(l.taskID, res.result)
}
mergingBy := ""
if finished && !handoff {
mergingBy, err = m.store.MergingBy(ctx, l.taskID)
+8 -1
View File
@@ -53,7 +53,14 @@ func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project,
if task.MergeQueuedAt != nil {
return api.Task{}, fmt.Errorf("%w: task %s is already waiting to merge", ErrConflict, task.Id)
}
title, err := cleanMergeTitle(title)
if task.MergingIntoParent {
return api.Task{}, fmt.Errorf("%w: task %s is merging into its parent", ErrConflict, task.Id)
}
err := m.checkNoSubtasks(ctx, task)
if err != nil {
return api.Task{}, err
}
title, err = cleanMergeTitle(title)
if err != nil {
return api.Task{}, err
}
+432
View File
@@ -0,0 +1,432 @@
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
)
func (m *Manager) subtaskTools(l *live) []mcp.Tool {
tool := func(name, description, schema string, run func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error)) mcp.Tool {
return mcp.Tool{
Name: name,
Description: description,
InputSchema: json.RawMessage(schema),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
sess, err := m.store.GetSession(ctx, l.id)
if err != nil {
return "", err
}
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
if err != nil {
return "", err
}
return run(ctx, creator, raw)
},
}
}
taskOnly := `{"type": "object", "properties": {"task_id": {"type": "string"}}, "required": ["task_id"]}`
return []mcp.Tool{
tool("create_subtask",
"Create a subtask of this task: a byre task with its own branch and worktree, started from this task's HEAD (your committed work; byre commits at the end of each turn), "+
"with an agent on it that gets prompt as its first message. Use it to run a piece of work in parallel or to get another model's view. "+
"The new agent sees nothing of your context, so the prompt must be a complete brief. "+
"Returns at once with the subtask's id. The final message of each of its turns reaches you as a message from byre; "+
"manage it with list_subtasks, read_subtask, message_subtask, interrupt_subtask, merge_subtask and discard_subtask. "+
"This task can't be merged or discarded while it has active subtasks.",
`{"type": "object", "properties": {
"title": {"type": "string", "description": "Short task title, shown in the task list."},
"prompt": {"type": "string", "description": "The new agent's first message: a complete, self-contained brief."},
"model": {"type": "string", "description": "Model for the new agent: gpt-* models run on Codex, every other model on Claude Code. Defaults to this session's model."}
}, "required": ["title", "prompt"]}`,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
args := struct {
Title string `json:"title"`
Prompt string `json:"prompt"`
Model string `json:"model"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed create_subtask call: %w", err)
}
if strings.TrimSpace(args.Title) == "" || strings.TrimSpace(args.Prompt) == "" {
return "", errors.New("title and prompt are required")
}
project, err := m.store.GetProject(ctx, l.projectID)
if err != nil {
return "", err
}
base, err := git.Head(l.dir)
if err != nil {
return "", err
}
task, err := m.createTask(ctx, project, api.Task{Title: args.Title, CreatedBy: creator.Login, ParentTaskId: &l.taskID}, nil, base, creator.Login)
if err != nil {
return "", err
}
_, err = m.create(ctx, task, creator, args.Prompt, m.modelOr(args.Model, l), agentActor, nil)
if err != nil {
return "", fmt.Errorf("subtask %s created, but its agent did not start: %w", task.Id, err)
}
return fmt.Sprintf("Subtask %s %q created from %s, branch %s, worktree %s, and its agent started.", task.Id, task.Title, task.BaseRef, task.Branch, task.WorktreePath), nil
}),
tool("list_subtasks",
"List this task's active subtasks: id, title, the agent's state and model, branch and worktree.",
`{"type": "object", "properties": {}}`,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
subs, err := m.store.ActiveSubtasks(ctx, l.taskID)
if err != nil {
return "", err
}
if len(subs) == 0 {
return "No active subtasks.", nil
}
b := &strings.Builder{}
for _, t := range subs {
agent := "no agent"
sess, found, err := m.liveSession(ctx, t.Id)
if err != nil {
return "", err
}
if found {
agent = fmt.Sprintf("agent %s on %s", sess.State, sess.Model)
}
fmt.Fprintf(b, "- %s %q: %s; branch %s; worktree %s", t.Id, t.Title, agent, t.Branch, t.WorktreePath)
if t.MergingIntoParent {
b.WriteString("; waiting for you to merge it")
}
b.WriteString("\n")
}
return b.String(), nil
}),
tool("read_subtask",
"Read a subtask's transcript: messages, tool calls (briefly), tool results (cut short), turn ends and inbox filings, each with its event seq. "+
"Without after_seq it returns the last limit events; with it, the events after that seq.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"after_seq": {"type": "integer"},
"limit": {"type": "integer", "description": "At most this many events, default 100, at most 500."}
}, "required": ["task_id"]}`,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
args := struct {
TaskID string `json:"task_id"`
AfterSeq *int64 `json:"after_seq"`
Limit int `json:"limit"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
task, err := m.subtask(ctx, l, args.TaskID, false)
if err != nil {
return "", err
}
return m.transcriptText(ctx, task.Id, args.AfterSeq, args.Limit)
}),
tool("message_subtask",
"Send a message to a subtask's agent. It starts a turn when the agent is idle, starts the agent when the subtask has none, "+
"and reaches it mid-turn when it is working.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"text": {"type": "string"}
}, "required": ["task_id", "text"]}`,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
args := struct {
TaskID string `json:"task_id"`
Text string `json:"text"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
if strings.TrimSpace(args.Text) == "" {
return "", errors.New("text is empty")
}
task, err := m.subtask(ctx, l, args.TaskID, true)
if err != nil {
return "", err
}
err = m.messageTask(ctx, task, creator, agentActor, args.Text)
if err != nil {
return "", err
}
return fmt.Sprintf("Sent to %s's agent.", task.Id), nil
}),
tool("interrupt_subtask", "Interrupt the turn a subtask's agent is running.", taskOnly,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
task, err := m.subtaskArg(ctx, l, raw)
if err != nil {
return "", err
}
sess, found, err := m.liveSession(ctx, task.Id)
if err != nil {
return "", err
}
if !found {
return "", fmt.Errorf("%w: subtask %s has no agent", ErrConflict, task.Id)
}
_, err = m.Interrupt(ctx, sess.Id)
if err != nil {
return "", err
}
return "Interrupted.", nil
}),
tool("merge_subtask",
"Merge a subtask. target parent: byre sends you a message asking you to merge the subtask's branch into this task's branch, "+
"and closes the subtask once its branch is in this branch's history at the end of one of your turns. "+
"target main: the subtask's agent merges the default branch in, then byre squashes it onto the default branch, pushes it and closes it, as the human's Merge does. "+
"Refused while the subtask's agent is mid-turn.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"target": {"type": "string", "enum": ["parent", "main"]}
}, "required": ["task_id", "target"]}`,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
args := struct {
TaskID string `json:"task_id"`
Target string `json:"target"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
task, err := m.subtask(ctx, l, args.TaskID, true)
if err != nil {
return "", err
}
switch args.Target {
case "parent":
_, err = m.MergeIntoParent(ctx, task, creator)
if err != nil {
return "", err
}
return fmt.Sprintf("Merge of %s into this task requested: byre's message asking you to merge %s follows once this turn ends.", task.Id, task.Branch), nil
case "main":
project, err := m.store.GetProject(ctx, task.ProjectId)
if err != nil {
return "", err
}
task, err = m.Merge(ctx, task, project, creator, task.MergeTitle)
if err != nil {
return "", err
}
if task.MergeQueuedAt != nil {
return fmt.Sprintf("Merge of %s held until the merge in progress in the project is done.", task.Id), nil
}
return fmt.Sprintf("Merge of %s started.", task.Id), nil
}
return "", fmt.Errorf("%w: target must be parent or main, not %q", ErrInvalid, args.Target)
}),
tool("discard_subtask",
"Discard a subtask: its agent gets one turn to write the task summary, then byre stops it and deletes its worktree and branch without merging. This destroys its unmerged work.",
taskOnly,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
task, err := m.subtaskArg(ctx, l, raw)
if err != nil {
return "", err
}
_, err = m.Discard(ctx, task, creator.Login)
if err != nil {
return "", err
}
return fmt.Sprintf("Discard of %s started: its agent writes the task summary, then byre closes it.", task.Id), nil
}),
}
}
func (m *Manager) subtaskArg(ctx context.Context, l *live, raw json.RawMessage) (api.Task, error) {
args := struct {
TaskID string `json:"task_id"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return api.Task{}, err
}
return m.subtask(ctx, l, args.TaskID, true)
}
func (m *Manager) subtask(ctx context.Context, l *live, taskID string, active bool) (api.Task, error) {
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
return api.Task{}, fmt.Errorf("task %s: %w", taskID, err)
}
if task.ParentTaskId == nil || *task.ParentTaskId != l.taskID {
return api.Task{}, fmt.Errorf("%w: task %s is not a subtask of this task", ErrInvalid, taskID)
}
if active && task.Status != api.TaskStatusActive {
return api.Task{}, fmt.Errorf("%w: subtask %s is %s", ErrConflict, task.Id, task.Status)
}
return task, nil
}
func (m *Manager) checkNoSubtasks(ctx context.Context, task api.Task) error {
subs, err := m.store.ActiveSubtasks(ctx, task.Id)
if err != nil {
return err
}
if len(subs) == 0 {
return nil
}
names := []string{}
for _, t := range subs {
names = append(names, fmt.Sprintf("%s %q", t.Id, t.Title))
}
return fmt.Errorf("%w: task %s has %d active subtasks; merge or discard them first: %s", ErrConflict, task.Id, len(subs), strings.Join(names, ", "))
}
func (m *Manager) messageTask(ctx context.Context, task api.Task, user api.User, actor, text string) error {
sess, found, err := m.liveSession(ctx, task.Id)
if err != nil {
return err
}
if !found {
_, err = m.create(ctx, task, user, text, "", actor, nil)
return err
}
_, err = m.Message(ctx, sess.Id, actor, text, "")
return err
}
func (m *Manager) MergeIntoParent(ctx context.Context, task api.Task, user api.User) (api.Task, error) {
if task.ParentTaskId == nil {
return api.Task{}, fmt.Errorf("%w: task %s is not a subtask", ErrInvalid, task.Id)
}
err := m.checkNoSubtasks(ctx, task)
if err != nil {
return api.Task{}, err
}
parent, _, err := m.activeTask(ctx, *task.ParentTaskId)
if err != nil {
return api.Task{}, err
}
sess, found, err := m.liveSession(ctx, task.Id)
if err != nil {
return api.Task{}, err
}
if found && sess.State == api.AgentSessionStateRunning {
return api.Task{}, fmt.Errorf("%w: subtask %s's agent is mid-turn; wait for the turn to end or interrupt it", ErrConflict, task.Id)
}
mergingBy, err := m.store.MergingBy(ctx, task.Id)
if err != nil {
return api.Task{}, err
}
if mergingBy != "" || task.MergeQueuedAt != nil {
return api.Task{}, fmt.Errorf("%w: subtask %s is merging into the default branch", ErrConflict, task.Id)
}
err = m.store.SetParentMerge(ctx, task.Id, user.Login)
switch {
case errors.Is(err, store.ErrNotFound):
return api.Task{}, fmt.Errorf("%w: subtask %s is closed", ErrConflict, task.Id)
case errors.Is(err, store.ErrDiscardPending), errors.Is(err, store.ErrParentMergeRequested):
return api.Task{}, fmt.Errorf("%w: subtask %s: %w", ErrConflict, task.Id, err)
case err != nil:
return api.Task{}, err
}
prompt := mergeInPrompt(task.Branch) + fmt.Sprintf(" This merges in your subtask %q (%s); byre closes the subtask once its branch is in this branch's history at the end of a turn.", task.Title, task.Id)
err = m.messageTask(ctx, parent, user, "byre", prompt)
if err != nil {
cerr := m.store.ClearParentMerge(ctx, task.Id)
if cerr != nil {
slog.Error("clear parent merge", "task", task.Id, "err", cerr)
}
return api.Task{}, err
}
return m.store.GetTask(ctx, task.Id)
}
func (m *Manager) closeMergedSubtasks(parentID, dir string) {
ctx := context.Background()
subs, err := m.store.ActiveSubtasks(ctx, parentID)
if err != nil {
slog.Error("list subtasks", "task", parentID, "err", err)
return
}
for _, t := range subs {
if !t.MergingIntoParent {
continue
}
err = m.closeMergedSubtask(ctx, t, parentID, dir)
if err != nil {
slog.Error("close merged subtask", "task", t.Id, "parent", parentID, "err", err)
}
}
}
func (m *Manager) closeMergedSubtask(ctx context.Context, t api.Task, parentID, dir string) error {
merged, err := git.IsAncestor(dir, t.Branch, "HEAD")
if err != nil || !merged {
return err
}
err = m.checkNoSubtasks(ctx, t)
if err != nil {
return err
}
login, err := m.store.ParentMergeBy(ctx, t.Id)
if err != nil {
return err
}
head, err := git.Head(dir)
if err != nil {
return err
}
project, err := m.store.GetProject(ctx, t.ProjectId)
if err != nil {
return err
}
err = m.RemoveTask(ctx, t, project, login)
if err != nil {
return err
}
_, err = m.store.MergeTaskIntoParent(ctx, t.Id, parentID, head, login)
if err != nil {
return err
}
slog.Info("subtask merged into parent", "task", t.Id, "parent", parentID, "commit", head)
return nil
}
func (m *Manager) reportToParent(taskID, result string) {
ctx := context.Background()
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
slog.Error("load subtask to report", "task", taskID, "err", err)
return
}
if task.ParentTaskId == nil || task.MergingIntoParent {
return
}
parent, err := m.store.GetTask(ctx, *task.ParentTaskId)
if err != nil {
slog.Error("load parent to report to", "task", taskID, "err", err)
return
}
if parent.Status != api.TaskStatusActive {
return
}
user, err := m.store.GetUser(ctx, task.CreatedBy)
if err != nil {
slog.Error("load subtask creator", "task", taskID, "err", err)
return
}
text := fmt.Sprintf("Your subtask %q (%s, branch %s) finished a turn:\n\n%s", task.Title, task.Id, task.Branch, quote(result))
err = m.messageTask(ctx, parent, user, "byre", text)
if err != nil {
slog.Error("report subtask turn to parent", "task", taskID, "parent", parent.Id, "err", err)
}
}
+7 -1
View File
@@ -138,7 +138,13 @@ func (m *Manager) Discard(ctx context.Context, task api.Task, actor string) (api
switch {
case errors.Is(err, store.ErrNotFound):
return api.Task{}, fmt.Errorf("%w: task %s is closed", ErrConflict, task.Id)
case errors.Is(err, store.ErrDiscardPending), errors.Is(err, store.ErrMerging):
case errors.Is(err, store.ErrHasSubtasks):
serr := m.checkNoSubtasks(ctx, task)
if serr == nil {
serr = fmt.Errorf("%w: task %s: %w", ErrConflict, task.Id, err)
}
return api.Task{}, serr
case errors.Is(err, store.ErrDiscardPending), errors.Is(err, store.ErrMerging), errors.Is(err, store.ErrMergingIntoParent):
return api.Task{}, fmt.Errorf("%w: task %s: %w", ErrConflict, task.Id, err)
case err != nil:
return api.Task{}, err
+8 -89
View File
@@ -2,16 +2,14 @@ package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/todo"
)
@@ -97,93 +95,14 @@ func (m *Manager) createTask(ctx context.Context, project api.Project, t api.Tas
err = m.store.CreateTask(ctx, t, frozen, actor)
if err != nil {
rerr := git.RemoveWorktree(project.RepoPath, t.WorktreePath, t.Branch)
if rerr != nil {
slog.Error("remove worktree of task not created", "task", t.Id, "err", rerr)
}
if errors.Is(err, store.ErrParentNotOpen) {
return api.Task{}, fmt.Errorf("%w: %w", ErrConflict, err)
}
return api.Task{}, err
}
return m.store.GetTask(ctx, t.Id)
}
type createTaskArgs struct {
Title string `json:"title"`
Prompt string `json:"prompt"`
Base string `json:"base"`
Model string `json:"model"`
}
func (m *Manager) createTaskTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "create_task",
Description: "Create a new byre task in this project, with its own branch and worktree, and start an agent on it with prompt as its first message. " +
"Use it to run independent pieces of work in parallel. The new agent sees nothing of your context, so the prompt must be a complete brief. " +
"base picks the starting commit: default_branch starts from the project's default branch; this_task starts from this task's HEAD, " +
"which includes your committed work but not uncommitted edits (byre commits at the end of each turn). " +
"Returns at once with the new task's id; the new agent runs on its own.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"title": {"type": "string", "description": "Short task title, shown in the task list."},
"prompt": {"type": "string", "description": "The new agent's first message: a complete, self-contained brief."},
"base": {"type": "string", "enum": ["default_branch", "this_task"]},
"model": {"type": "string", "description": "Model for the new agent. Defaults to this session's model."}
},
"required": ["title", "prompt", "base"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.createTaskFromAgent(ctx, l, raw)
},
}
}
func (m *Manager) createTaskFromAgent(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
args := createTaskArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed create_task call: %w", err)
}
if strings.TrimSpace(args.Title) == "" || strings.TrimSpace(args.Prompt) == "" {
return "", errors.New("title and prompt are required")
}
sess, err := m.store.GetSession(ctx, l.id)
if err != nil {
return "", err
}
project, err := m.store.GetProject(ctx, l.projectID)
if err != nil {
return "", err
}
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
if err != nil {
return "", err
}
base := ""
switch args.Base {
case "default_branch":
base = project.DefaultBranch
case "this_task":
base, err = git.Head(l.dir)
if err != nil {
return "", err
}
default:
return "", fmt.Errorf("base must be default_branch or this_task, not %q", args.Base)
}
model := args.Model
if model == "" {
model = sess.Model
}
task, err := m.CreateTask(ctx, project, args.Title, creator.Login, base, creator.Login)
if err != nil {
return "", err
}
_, err = m.create(ctx, task, creator, args.Prompt, model, agentActor, nil)
if err != nil {
return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
}
return fmt.Sprintf("Task %s %q created from %s and its agent started.", task.Id, task.Title, task.BaseRef), nil
}
+131 -96
View File
@@ -390,6 +390,24 @@ func (e ZombieKind) Valid() bool {
}
}
// Defines values for MergeTaskJSONBodyTarget.
const (
MergeTargetMain MergeTaskJSONBodyTarget = "main"
MergeTargetParent MergeTaskJSONBodyTarget = "parent"
)
// Valid indicates whether the value is a known member of the MergeTaskJSONBodyTarget enum.
func (e MergeTaskJSONBodyTarget) Valid() bool {
switch e {
case MergeTargetMain:
return true
case MergeTargetParent:
return true
default:
return false
}
}
// AgentProcess defines model for AgentProcess.
type AgentProcess struct {
// Health stuck means a turn is running and no stream event has arrived for stuck_after_seconds; exited means the process is gone.
@@ -881,6 +899,12 @@ type Task struct {
// Merging Whether a Merge was started and its turn hasn't ended yet.
Merging bool `json:"merging"`
// MergingIntoParent Whether a merge of this subtask into its parent was requested and the parent hasn't merged it yet.
MergingIntoParent bool `json:"merging_into_parent"`
// ParentTaskId The task whose agent created this one as its subtask; absent for other tasks.
ParentTaskId *string `json:"parent_task_id,omitempty"`
// PortBase The first of the task's block of ports (port_base to port_base+9), passed to its agent and terminal as PORT.
PortBase int `json:"port_base"`
ProjectId string `json:"project_id"`
@@ -1089,10 +1113,16 @@ type GetTaskImageParams struct {
// MergeTaskJSONBody defines parameters for MergeTask.
type MergeTaskJSONBody struct {
// MergeTitle The squash commit's title: one line, at most 72 characters.
MergeTitle string `json:"merge_title"`
// MergeTitle The squash commit's title: one line, at most 72 characters. Required for target main.
MergeTitle *string `json:"merge_title,omitempty"`
// Target main (the default): squash onto the project's default branch. parent: merge into the parent task's branch; only for a subtask.
Target *MergeTaskJSONBodyTarget `json:"target,omitempty"`
}
// MergeTaskJSONBodyTarget defines parameters for MergeTask.
type MergeTaskJSONBodyTarget string
// GetTaskTerminalParams defines parameters for GetTaskTerminal.
type GetTaskTerminalParams struct {
Cols int `form:"cols" json:"cols"`
@@ -2991,100 +3021,105 @@ var swaggerSpec = []string{
"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",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+14
View File
@@ -263,6 +263,20 @@ func Head(dir string) (string, error) {
return run(dir, "rev-parse", "HEAD")
}
func IsAncestor(dir, ancestor, descendant string) (bool, error) {
_, stderr, code, err := runCode(dir, nil, "merge-base", "--is-ancestor", ancestor, descendant)
if err != nil {
return false, err
}
switch code {
case 0:
return true, nil
case 1:
return false, nil
}
return false, fmt.Errorf("git merge-base --is-ancestor %s %s: exit status %d: %s", ancestor, descendant, code, stderr)
}
func ReadFile(repo, rev, path string) (string, bool, error) {
_, _, code, err := runCode(repo, nil, "cat-file", "-e", rev+":"+path)
if err != nil {
+19 -1
View File
@@ -396,7 +396,25 @@ func (s *Server) MergeTask(w http.ResponseWriter, r *http.Request, taskID string
return
}
t, err = s.agents.Merge(r.Context(), t, p, u, req.MergeTitle)
if req.Target != nil && *req.Target == api.MergeTargetParent {
t, err = s.agents.MergeIntoParent(r.Context(), t, u)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, t)
return
}
if req.Target != nil && !req.Target.Valid() {
writeError(w, http.StatusBadRequest, fmt.Errorf("unknown target %q", *req.Target))
return
}
if req.MergeTitle == nil {
writeError(w, http.StatusBadRequest, errors.New("merge_title is required to merge into the default branch"))
return
}
t, err = s.agents.Merge(r.Context(), t, p, u, *req.MergeTitle)
if err != nil {
writeAgentError(w, err)
return
+16 -3
View File
@@ -19,8 +19,12 @@ const (
)
var (
ErrDiscardPending = errors.New("the task is already being discarded")
ErrMerging = errors.New("the task is merging")
ErrDiscardPending = errors.New("the task is already being discarded")
ErrMerging = errors.New("the task is merging")
ErrMergingIntoParent = errors.New("the task is merging into its parent task")
ErrHasSubtasks = errors.New("the task has active subtasks")
ErrParentNotOpen = errors.New("the parent task is closed or being discarded")
ErrParentMergeRequested = errors.New("the subtask is already merging into its parent")
)
type Discard struct {
@@ -44,7 +48,12 @@ func (s *Store) RequestDiscard(ctx context.Context, taskID, actor string) (Disca
projectID := ""
status := ""
mergingBy := ""
err := tx.QueryRowContext(ctx, `SELECT project_id, status, merging_by, discard_state FROM tasks WHERE id = ?`, taskID).Scan(&projectID, &status, &mergingBy, &state)
parentMergeBy := ""
subtasks := 0
err := tx.QueryRowContext(ctx, `
SELECT project_id, status, merging_by, parent_merge_by, discard_state,
(SELECT count(*) FROM tasks AS sub WHERE sub.parent_task_id = tasks.id AND sub.status = ?)
FROM tasks WHERE id = ?`, string(api.TaskStatusActive), taskID).Scan(&projectID, &status, &mergingBy, &parentMergeBy, &state, &subtasks)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
@@ -60,6 +69,10 @@ func (s *Store) RequestDiscard(ctx context.Context, taskID, actor string) (Disca
return ErrDiscardPending
case mergingBy != "":
return ErrMerging
case parentMergeBy != "":
return ErrMergingIntoParent
case subtasks > 0:
return ErrHasSubtasks
}
state = DiscardHeld
_, err = tx.ExecContext(ctx, `UPDATE tasks SET discard_state = ?, discarding_by = ?, discard_session = '', merge_queued_by = '', merge_queued_at = '' WHERE id = ?`,
+88 -5
View File
@@ -421,6 +421,10 @@ ALTER TABLE tasks ADD COLUMN discarding_by TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN discard_state TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN discard_session TEXT NOT NULL DEFAULT '';
UPDATE tasks SET discard_state = 'held' WHERE discarding_by != '' AND status = 'active';
`}, {"add-subtasks", `
ALTER TABLE tasks ADD COLUMN parent_task_id TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN parent_merge_by TEXT NOT NULL DEFAULT '';
CREATE INDEX tasks_parent ON tasks(parent_task_id) WHERE parent_task_id != '';
`}}
const (
@@ -722,7 +726,22 @@ func (s *Store) CreateTask(ctx context.Context, t api.Task, frozen *FrozenWorkfl
if t.ForkedAt != nil {
forkedAt = *t.ForkedAt
}
parentTaskID := ""
if t.ParentTaskId != nil {
parentTaskID = *t.ParentTaskId
}
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
if parentTaskID != "" {
open := false
err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM tasks WHERE id = ? AND status = ? AND discard_state = '')`,
parentTaskID, string(api.TaskStatusActive)).Scan(&open)
if err != nil {
return err
}
if !open {
return ErrParentNotOpen
}
}
err := tx.QueryRowContext(ctx, `
SELECT min(candidate) FROM (
SELECT ? AS candidate
@@ -742,9 +761,9 @@ func (s *Store) CreateTask(ctx context.Context, t api.Task, frozen *FrozenWorkfl
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO tasks (id, project_id, title, merge_title, branch, worktree_path, base_ref, port_base, color, status, created_by, created_at, todo_item, forked_from, forked_at, workflow_name, workflow_definition, workflow_brief, workflow_title, workflow_run, workflow_stage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, t.Id, t.ProjectId, t.Title, mergeTitle, t.Branch, t.WorktreePath, t.BaseRef, t.PortBase, t.Color, string(t.Status), t.CreatedBy, formatTime(t.CreatedAt), todoItem, forkedFrom, forkedAt, workflowName, workflowDefinition, workflowBrief, workflowTitle, workflowRun, workflowStage)
INSERT INTO tasks (id, project_id, title, merge_title, branch, worktree_path, base_ref, port_base, color, status, created_by, created_at, todo_item, forked_from, forked_at, workflow_name, workflow_definition, workflow_brief, workflow_title, workflow_run, workflow_stage, parent_task_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, t.Id, t.ProjectId, t.Title, mergeTitle, t.Branch, t.WorktreePath, t.BaseRef, t.PortBase, t.Color, string(t.Status), t.CreatedBy, formatTime(t.CreatedAt), todoItem, forkedFrom, forkedAt, workflowName, workflowDefinition, workflowBrief, workflowTitle, workflowRun, workflowStage, parentTaskID)
if err != nil {
return err
}
@@ -871,6 +890,60 @@ func (s *Store) queryTasks(ctx context.Context, query string, args ...any) ([]ap
return tasks, rows.Err()
}
func (s *Store) ActiveSubtasks(ctx context.Context, parentID string) ([]api.Task, error) {
return s.queryTasks(ctx, `SELECT `+taskColumns+` FROM tasks WHERE parent_task_id = ? AND status = ? ORDER BY created_at`, parentID, string(api.TaskStatusActive))
}
func (s *Store) SetParentMerge(ctx context.Context, id, login string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
projectID, parentID, status, parentMergeBy, discardState := "", "", "", "", ""
err := tx.QueryRowContext(ctx, `SELECT project_id, parent_task_id, status, parent_merge_by, discard_state FROM tasks WHERE id = ?`, id).
Scan(&projectID, &parentID, &status, &parentMergeBy, &discardState)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
switch {
case status != string(api.TaskStatusActive) || parentID == "":
return ErrNotFound
case discardState != "":
return ErrDiscardPending
case parentMergeBy != "":
return ErrParentMergeRequested
}
_, err = tx.ExecContext(ctx, `UPDATE tasks SET parent_merge_by = ? WHERE id = ?`, login, id)
if err != nil {
return err
}
return emit(api.Event{Kind: "task.parent_merge_started", ProjectId: &projectID, TaskId: &id, Actor: login, Payload: map[string]any{"id": id, "parent_task_id": parentID}})
})
}
func (s *Store) ClearParentMerge(ctx context.Context, id string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
projectID := ""
err := tx.QueryRowContext(ctx, `UPDATE tasks SET parent_merge_by = '' WHERE id = ? AND parent_merge_by != '' RETURNING project_id`, id).Scan(&projectID)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
return emit(api.Event{Kind: "task.parent_merge_stopped", ProjectId: &projectID, TaskId: &id, Actor: "byre", Payload: map[string]any{"id": id}})
})
}
func (s *Store) ParentMergeBy(ctx context.Context, id string) (string, error) {
login := ""
err := s.db.QueryRowContext(ctx, `SELECT parent_merge_by FROM tasks WHERE id = ?`, id).Scan(&login)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return login, err
}
func (s *Store) QueueMerge(ctx context.Context, id, login string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
projectID := ""
@@ -977,6 +1050,10 @@ func (s *Store) MergeTask(ctx context.Context, id, commit, pushed, actor string)
return s.closeTask(ctx, id, api.TaskStatusMerged, commit, actor, "task.merged", map[string]any{"id": id, "commit": commit, "pushed": pushed})
}
func (s *Store) MergeTaskIntoParent(ctx context.Context, id, parentID, commit, actor string) (api.Task, error) {
return s.closeTask(ctx, id, api.TaskStatusMerged, commit, actor, "task.merged", map[string]any{"id": id, "commit": commit, "parent_task_id": parentID})
}
func (s *Store) DiscardTask(ctx context.Context, id, actor string) (api.Task, error) {
return s.closeTask(ctx, id, api.TaskStatusDiscarded, "", actor, "task.discarded", map[string]any{"id": id})
}
@@ -1436,7 +1513,7 @@ func (s *Store) write(ctx context.Context, fn func(tx *sql.Tx, emit emitFunc) er
const projectColumns = `id, name, repo_path, default_branch, remote, created_by, created_at`
const taskColumns = `id, project_id, title, merge_title, branch, worktree_path, base_ref, port_base, color, status, created_by, created_at, closed_at, merge_commit, todo_item, forked_from, forked_at, merge_queued_at, workflow_name, workflow_run, workflow_stage, EXISTS (SELECT 1 FROM reviews WHERE reviews.task_id = tasks.id AND reviews.state = 'running'), merging_by != '', discard_state != '' AND status = 'active'`
const taskColumns = `id, project_id, title, merge_title, branch, worktree_path, base_ref, port_base, color, status, created_by, created_at, closed_at, merge_commit, todo_item, forked_from, forked_at, merge_queued_at, workflow_name, workflow_run, workflow_stage, parent_task_id, parent_merge_by, EXISTS (SELECT 1 FROM reviews WHERE reviews.task_id = tasks.id AND reviews.state = 'running'), merging_by != '', discard_state != '' AND status = 'active'`
const sessionColumns = `id, coalesce(task_id, ''), owner, vendor, model, vendor_session_id, state, role, created_by, created_at, forked_from, forked_at`
@@ -1468,10 +1545,16 @@ func scanTask(row scanner) (api.Task, error) {
workflowName := ""
workflowRun := ""
workflowStage := ""
err := row.Scan(&t.Id, &t.ProjectId, &t.Title, &t.MergeTitle, &t.Branch, &t.WorktreePath, &t.BaseRef, &t.PortBase, &t.Color, &status, &t.CreatedBy, &createdAt, &closedAt, &mergeCommit, &todoItem, &forkedFrom, &forkedAt, &mergeQueuedAt, &workflowName, &workflowRun, &workflowStage, &t.Reviewing, &t.Merging, &t.Discarding)
parentTaskID := ""
parentMergeBy := ""
err := row.Scan(&t.Id, &t.ProjectId, &t.Title, &t.MergeTitle, &t.Branch, &t.WorktreePath, &t.BaseRef, &t.PortBase, &t.Color, &status, &t.CreatedBy, &createdAt, &closedAt, &mergeCommit, &todoItem, &forkedFrom, &forkedAt, &mergeQueuedAt, &workflowName, &workflowRun, &workflowStage, &parentTaskID, &parentMergeBy, &t.Reviewing, &t.Merging, &t.Discarding)
if err != nil {
return t, err
}
if parentTaskID != "" {
t.ParentTaskId = &parentTaskID
}
t.MergingIntoParent = parentMergeBy != ""
if workflowName != "" {
t.Workflow = &workflowName
t.WorkflowRun = &workflowRun