Run workflow stages with finish_stage handoffs and lead signals

This commit is contained in:
Ian Gulliver
2026-10-15 22:11:25 -07:00
parent 0c698cd3c4
commit 2eccd23a98
15 changed files with 665 additions and 256 deletions
+37 -8
View File
@@ -156,15 +156,16 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
tool("create_task",
"Create a task with its own branch and worktree, and start an agent on it with prompt as its first message. "+
"The new agent sees nothing else, so the prompt must be a complete brief. It starts from the project's default branch, "+
"or from from_task_id's committed HEAD.",
"or from from_task_id's committed HEAD. With workflow, the task runs that workflow's stages in order instead, each in a fresh session on the stage's model; "+
"prompt is then optional and is added to every stage's first message as its brief, and model can't be given.",
`{"type": "object", "properties": {
"project_id": {"type": "string"},
"title": {"type": "string"},
"prompt": {"type": "string"},
"from_task_id": {"type": "string"},
"model": {"type": "string"},
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of."}
}, "required": ["project_id", "title", "prompt"]}`,
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."}
}, "required": ["project_id", "title"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
ProjectID string `json:"project_id"`
@@ -178,8 +179,14 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
if err != nil {
return "", err
}
if strings.TrimSpace(args.Title) == "" || strings.TrimSpace(args.Prompt) == "" {
return "", errors.New("title and prompt are required")
if strings.TrimSpace(args.Title) == "" {
return "", errors.New("title is required")
}
if args.Workflow == "" && strings.TrimSpace(args.Prompt) == "" {
return "", errors.New("prompt is required without a workflow")
}
if args.Workflow != "" && args.Model != "" {
return "", errModelWithWorkflow
}
if user.GitName == "" || user.GitEmail == "" {
return "", ErrNoGitIdentity
@@ -199,10 +206,17 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
return "", err
}
}
task, err := m.CreateTask(ctx, project, args.Title, user.Login, base, leadActor(user.Login), args.Workflow)
task, err := m.CreateTask(ctx, project, args.Title, user.Login, base, leadActor(user.Login), args.Workflow, args.Prompt)
if err != nil {
return "", err
}
if args.Workflow != "" {
err = m.startWorkflow(ctx, task, user, leadActor(user.Login))
if err != nil {
return "", err
}
return fmt.Sprintf("Task %s %q created from %s and its workflow %q started.", task.Id, task.Title, task.BaseRef, args.Workflow), nil
}
_, err = m.create(ctx, task, user, args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), nil)
if err != nil {
return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
@@ -211,12 +225,13 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
}),
tool("start_todo",
"Start a task on an item of a project's TODO.md (its title, exactly as list_tasks' project or the file on the default branch has it).",
"Start a task on an item of a project's TODO.md (its title, exactly as list_tasks' project or the file on the default branch has it). "+
"With workflow, the task runs that workflow's stages with the item as their brief, and model can't be given.",
`{"type": "object", "properties": {
"project_id": {"type": "string"},
"title": {"type": "string"},
"model": {"type": "string"},
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of."}
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."}
}, "required": ["project_id", "title"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
@@ -229,6 +244,9 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
if err != nil {
return "", err
}
if args.Workflow != "" && args.Model != "" {
return "", errModelWithWorkflow
}
project, err := m.store.GetProject(ctx, args.ProjectID)
if err != nil {
return "", err
@@ -562,6 +580,13 @@ 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.Workflow != nil {
stage, err := m.workflowStatus(ctx, t.Id)
if err != nil {
return "", err
}
fmt.Fprintf(b, "; workflow %q, %s", *t.Workflow, stage)
}
b.WriteString("\n")
}
if !listed {
@@ -674,6 +699,10 @@ func transcriptLine(ev api.Event, root string) string {
}
case "task.merged", "task.discarded":
return fmt.Sprintf("%s by %s", ev.Kind, ev.Actor)
case "task.workflow_stage":
return fmt.Sprintf("started stage %v/%v of workflow %q: %s", p["stage"], p["stages"], text("workflow"), text("name"))
case "task.workflow_finished":
return fmt.Sprintf("finished workflow %q: %s", text("workflow"), clip(text("output"), 300))
case "review.finished":
r := api.Review{}
err := remarshal(p, &r)