Add scheduled tasks with a Project tab section and lead tools

This commit is contained in:
Ian Gulliver
2026-10-21 15:15:20 -07:00
parent 4b4763779d
commit f445e1cac3
21 changed files with 1689 additions and 347 deletions
+16 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"time"
)
const abandonedMergeText = "The Merge turn ended unfinished (interrupted, failed to start, or with no final message), so byre didn't squash or push the task. Merge again to retry."
@@ -17,21 +18,29 @@ func (m *Manager) poke() {
func (m *Manager) coordinate() {
started := map[string]bool{}
timer := time.NewTimer(time.Hour)
timer.Stop()
for {
select {
case <-m.watchStop:
return
case <-m.pokes:
m.coordinatePass(context.Background(), started)
case <-timer.C:
}
next := m.coordinatePass(context.Background(), started)
timer.Stop()
if next != nil {
timer.Reset(time.Until(*next))
}
}
}
func (m *Manager) coordinatePass(ctx context.Context, started map[string]bool) {
func (m *Manager) coordinatePass(ctx context.Context, started map[string]bool) *time.Time {
work, err := m.store.Coordinate(ctx)
if err != nil {
slog.Error("coordinate", "err", err)
return
retry := time.Now().Add(time.Minute)
return &retry
}
for _, id := range work.AbandonedMerges {
slog.Info("merge turn ended unfinished", "task", id)
@@ -84,9 +93,13 @@ func (m *Manager) coordinatePass(ctx context.Context, started map[string]bool) {
for _, p := range work.ReadyPrompts {
once("ready:"+p.TaskID, func() { m.promptReady(p) })
}
for _, sc := range work.DueSchedules {
once(fmt.Sprintf("schedule:%s:%d", sc.Id, sc.NextRun.UnixNano()), func() { m.runSchedule(sc) })
}
for key := range started {
if !current[key] {
delete(started, key)
}
}
return work.NextSchedule
}
+1
View File
@@ -34,6 +34,7 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left
- You can create tasks, start TODO items, message, interrupt and compact task agents, and merge or discard tasks. Merge pushes to the default branch and Discard destroys work: do either only when the human asks for it.
- Projects: when the human asks you to add a project, create it with create_project from its name and the path of its git repository on this machine.
- Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. A workflow is a tree of steps run in order, each a stage, an order or a parallel; every stage runs as its own task on the stage's model once the step before it has finished, so stages fan out (a parallel after a stage) and fan in (a stage after a parallel); a stage's agent calls finish_stage to hand its output to the stages after it, signals tell you when a stage starts and when the workflow finishes, and the workflow finishes once every stage has; byre then discards the run's other stage tasks, leaving the task of each last stage (one per branch when it ends in a parallel) to merge. Each last stage's agent is asked whether its task is ready to merge as soon as its own stage has finished and been reviewed, without waiting for the other last stages. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no models, for example a fenced mermaid block holding "flowchart LR", "Build --> Review", "Build --> Test", "Review --> Fix" and "Test --> Fix".
- Schedules: when the human asks for work to run on a timer (every Monday, nightly), you manage it with list_schedules, save_schedule and delete_schedule; the human sees and edits the same schedules in the Project tab. On each run, byre creates a task from a schedule's prompt or workflow as create_task would, created by byre, and you get its created signal; a run that fails to start reaches you as a signal.
- Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, for example [Fix login redirect](#/task/tskDa7e4K7305e). byre draws it in that task's color, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task/<task_id>/<tab>. When you point the human at a question to answer or a finished turn, link to #/task/<task_id>/transcript.
- Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line <details><summary>Title</summary>, the Markdown content, then a line </details>.
- You are long-running. Your context window is large. byre sends you a signal once your context passes a quarter of its window; then call compact_self at a logical point (a piece of work wrapped up, a topic closed), and not before; when the turn ends, byre replaces you with a fresh session that starts from your handoff.
+1 -1
View File
@@ -591,7 +591,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
m.mu.Unlock()
return "byre will ask for your handoff once this turn ends.", nil
}),
}, append(m.workflowTools(tool), m.noteTools(tool)...)...)
}, append(append(m.workflowTools(tool), m.scheduleTools(l, tool)...), m.noteTools(tool)...)...)
}
func (m *Manager) SetFocus(ctx context.Context, login string, f api.Focus, actor string) error {
+263
View File
@@ -0,0 +1,263 @@
package agents
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/ids"
"g.fc.run/ai/byre/internal/mcp"
"g.fc.run/ai/byre/internal/schedule"
"g.fc.run/ai/byre/internal/store"
)
func (m *Manager) SaveSchedule(ctx context.Context, projectID, scheduleID string, save api.ScheduleSave, login, actor string) (api.Schedule, error) {
sc, err := schedule.Timing(save)
if err != nil {
return api.Schedule{}, err
}
sc.Prompt = strings.TrimSpace(deref(save.Prompt))
sc.Model = strings.TrimSpace(deref(save.Model))
sc.Workflow = strings.TrimSpace(deref(save.Workflow))
switch {
case sc.Workflow != "" && sc.Model != "":
return api.Schedule{}, errModelWithWorkflow
case sc.Workflow == "" && sc.Prompt == "":
return api.Schedule{}, fmt.Errorf("%w: prompt is required without a workflow", ErrInvalid)
case sc.Workflow == "" && sc.Model == "":
return api.Schedule{}, fmt.Errorf("%w: model is required without a workflow", ErrInvalid)
case sc.Workflow != "":
_, err = m.store.GetWorkflow(ctx, sc.Workflow)
if err != nil {
return api.Schedule{}, err
}
}
if scheduleID != "" {
existing, err := m.store.GetSchedule(ctx, scheduleID)
if err != nil {
return api.Schedule{}, err
}
sc.Id = existing.Id
sc.ProjectId = existing.ProjectId
sc.Owner = existing.Owner
sc, err = m.store.UpdateSchedule(ctx, sc, actor)
if err != nil {
return api.Schedule{}, err
}
m.poke()
return sc, nil
}
user, err := m.store.GetUser(ctx, login)
if err != nil {
return api.Schedule{}, err
}
if user.GitName == "" || user.GitEmail == "" {
return api.Schedule{}, ErrNoGitIdentity
}
project, err := m.store.GetProject(ctx, projectID)
if err != nil {
return api.Schedule{}, err
}
sc.Id = ids.New(ids.Schedule)
sc.ProjectId = project.Id
sc.Owner = user.Login
sc, err = m.store.CreateSchedule(ctx, sc, actor)
if err != nil {
return api.Schedule{}, err
}
m.poke()
return sc, nil
}
func (m *Manager) DeleteSchedule(ctx context.Context, scheduleID, actor string) error {
err := m.store.DeleteSchedule(ctx, scheduleID, actor)
if err != nil {
return err
}
m.poke()
return nil
}
func (m *Manager) runSchedule(sc api.Schedule) {
ctx := context.Background()
err := m.startScheduled(ctx, sc)
if err == nil {
slog.Info("scheduled task started", "schedule", sc.Id, "project", sc.ProjectId)
} else {
slog.Error("start scheduled task", "schedule", sc.Id, "project", sc.ProjectId, "err", err)
m.signalSchedule(ctx, sc, fmt.Sprintf("Schedule %s %q didn't start its run: %v.", sc.Id, sc.Title, err))
}
for i := 0; ; i++ {
err = m.store.AdvanceSchedule(ctx, sc)
if err == nil {
break
}
slog.Error("advance schedule", "schedule", sc.Id, "err", err)
if i == 0 {
m.signalSchedule(ctx, sc, fmt.Sprintf("Schedule %s %q couldn't move on to its next run: %v. byre retries every minute; the schedule doesn't run again until it does.", sc.Id, sc.Title, err))
}
time.Sleep(time.Minute)
}
m.poke()
}
func (m *Manager) signalSchedule(ctx context.Context, sc api.Schedule, text string) {
err := m.addLeadInput(ctx, store.LeadInput{Owner: sc.Owner, Kind: store.LeadSignal, Actor: "byre", Text: text})
if err != nil {
slog.Error("signal lead", "schedule", sc.Id, "err", err)
}
}
func (m *Manager) startScheduled(ctx context.Context, sc api.Schedule) error {
project, err := m.store.GetProject(ctx, sc.ProjectId)
if err != nil {
return err
}
user, err := m.store.GetUser(ctx, sc.Owner)
if err != nil {
return err
}
if user.GitName == "" || user.GitEmail == "" {
return ErrNoGitIdentity
}
if sc.Workflow != "" {
_, err = m.StartWorkflow(ctx, project, user, sc.Title, project.DefaultBranch, "byre", sc.Workflow, sc.Prompt, nil, "")
return err
}
task, err := m.CreateTask(ctx, project, sc.Title, user.Login, project.DefaultBranch, "byre")
if err != nil {
return err
}
_, err = m.create(ctx, task, user, "", sc.Prompt, sc.Model, "byre", "", "", nil)
if err != nil {
return fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
}
return nil
}
func scheduleText(sc api.Schedule) string {
b := &strings.Builder{}
fmt.Fprintf(b, "- %s %q: project %s, owner %s, every %s", sc.Id, sc.Title, sc.ProjectId, sc.Owner, sc.Every)
switch sc.Every {
case api.Hour:
fmt.Fprintf(b, " at minute %d", sc.Minute)
case api.Week:
days := []string{}
for _, d := range sc.Weekdays {
days = append(days, string(d))
}
fmt.Fprintf(b, " on %s at %02d:%02d", strings.Join(days, ", "), sc.Hour, sc.Minute)
case api.Month:
fmt.Fprintf(b, " on day %d at %02d:%02d", sc.DayOfMonth, sc.Hour, sc.Minute)
default:
fmt.Fprintf(b, " at %02d:%02d", sc.Hour, sc.Minute)
}
fmt.Fprintf(b, ", next run %s", sc.NextRun.Local().Format(time.RFC3339))
if sc.Workflow != "" {
fmt.Fprintf(b, ", workflow %q", sc.Workflow)
} else {
fmt.Fprintf(b, ", model %s", sc.Model)
}
if sc.Prompt != "" {
fmt.Fprintf(b, "\n prompt: %s", clip(sc.Prompt, 500))
}
return b.String()
}
func (m *Manager) scheduleTools(l *live, tool func(name, description, schema string, run func(ctx context.Context, user api.User, raw json.RawMessage) (string, error)) mcp.Tool) []mcp.Tool {
return []mcp.Tool{
tool("list_schedules",
"List the schedules: each one's id, project, owner, title, timing, model or workflow, prompt, and next run in the server's local time. Give project_id for one project's.",
`{"type": "object", "properties": {"project_id": {"type": "string"}}}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
ProjectID string `json:"project_id"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
list, err := m.store.ListSchedules(ctx, args.ProjectID)
if err != nil {
return "", err
}
if len(list) == 0 {
return "No schedules.", nil
}
lines := []string{}
for _, sc := range list {
lines = append(lines, scheduleText(sc))
}
return strings.Join(lines, "\n"), nil
}),
tool("save_schedule",
"Create a schedule, or with schedule_id replace one's settings (project_id is then ignored). On each run byre creates a task in the project from its default branch, as create_task does, "+
"as the schedule's owner (you, for one you create), and you get its created signal. Times are the server's local time. "+
"every hour runs at minute past each hour (hour 0); day at hour:minute; week at hour:minute on each of weekdays; month at hour:minute on day_of_month, or the month's last day when shorter. "+
"weekdays only for week, day_of_month only for month. Each run's agent sees nothing else, so the prompt must be a complete brief. "+
"With workflow, each run starts that workflow, prompt is optional and is its brief, and model can't be given; the workflow is looked up by name at each run. "+
"A run missed while byre was down happens once when it starts. The human sees and edits schedules in the Project tab. Only when the human asks.",
`{"type": "object", "properties": {
"project_id": {"type": "string"},
"schedule_id": {"type": "string"},
"title": {"type": "string", "description": "The title of each task it creates."},
"every": {"type": "string", "enum": ["hour", "day", "week", "month"]},
"weekdays": {"type": "array", "items": {"type": "string", "enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]}},
"hour": {"type": "integer", "minimum": 0, "maximum": 23},
"minute": {"type": "integer", "minimum": 0, "maximum": 59},
"day_of_month": {"type": "integer", "minimum": 1, "maximum": 31},
"prompt": {"type": "string"},
"model": {"type": "string", "description": "Without workflow, the agent's model; defaults to yours."},
"workflow": {"type": "string", "description": "The name of a workflow for each run to freeze a copy of and run."}
}, "required": ["title", "every", "hour", "minute"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
ProjectID string `json:"project_id"`
ScheduleID string `json:"schedule_id"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
save := api.ScheduleSave{}
err = json.Unmarshal(raw, &save)
if err != nil {
return "", err
}
if deref(save.Workflow) == "" {
model := m.modelOr(deref(save.Model), l)
save.Model = &model
}
sc, err := m.SaveSchedule(ctx, args.ProjectID, args.ScheduleID, save, user.Login, leadActor(user.Login))
if err != nil {
return "", err
}
return "Saved:\n" + scheduleText(sc), nil
}),
tool("delete_schedule",
"Delete a schedule. Tasks it already created stay. Only when the human asks.",
`{"type": "object", "properties": {"schedule_id": {"type": "string"}}, "required": ["schedule_id"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
ScheduleID string `json:"schedule_id"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
err = m.DeleteSchedule(ctx, args.ScheduleID, leadActor(user.Login))
if err != nil {
return "", err
}
return fmt.Sprintf("Deleted schedule %s.", args.ScheduleID), nil
}),
}
}