2026-10-21 15:15:20 -07:00
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. " +
2026-10-21 20:53:54 -07:00
"A run missed while byre was down happens once when it starts. The human sees and edits schedules on the project's dashboard. Only when the human asks." ,
2026-10-21 15:15:20 -07:00
`{"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
}),
}
}