237 lines
6.3 KiB
Go
237 lines
6.3 KiB
Go
package statemachine
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"go.temporal.io/sdk/workflow"
|
||
|
|
"github.com/rockliang/poimen/workflows/internal/recovery"
|
||
|
|
"github.com/rockliang/poimen/workflows/internal/logging"
|
||
|
|
)
|
||
|
|
|
||
|
|
// OrchestratorWorkflowWithRecovery orchestrates multi-agent work with recovery capabilities
|
||
|
|
// It differs from the basic orchestrator by:
|
||
|
|
// 1. Using retry policies for all activities
|
||
|
|
// 2. Tracking workflow state via checkpoints
|
||
|
|
// 3. Using deadletter handling for permanently failed activities
|
||
|
|
// 4. Resuming from checkpoints after crashes
|
||
|
|
func OrchestratorWorkflowWithRecovery(ctx workflow.Context, in OrchestratorInput) (OrchestratorOutput, error) {
|
||
|
|
output := OrchestratorOutput{
|
||
|
|
MilestoneComplete: false,
|
||
|
|
Done: false,
|
||
|
|
LastError: "",
|
||
|
|
}
|
||
|
|
|
||
|
|
logger := logging.GetLogger()
|
||
|
|
|
||
|
|
// Create activity options with retry policy
|
||
|
|
retryPolicy := recovery.ActivityRetryPolicy()
|
||
|
|
baseActivityOptions := workflow.ActivityOptions{
|
||
|
|
StartToCloseTimeout: 10 * time.Minute,
|
||
|
|
ScheduleToCloseTimeout: 15 * time.Minute,
|
||
|
|
RetryPolicy: retryPolicy.ToTemporalRetryPolicy(),
|
||
|
|
}
|
||
|
|
|
||
|
|
ctxWithOptions := workflow.WithActivityOptions(ctx, baseActivityOptions)
|
||
|
|
|
||
|
|
// Step 1: Clone the repository with retry
|
||
|
|
logger.Info("starting orchestrator workflow",
|
||
|
|
logging.String("milestone", in.Milestone),
|
||
|
|
logging.String("repo", in.TargetRepoPath))
|
||
|
|
|
||
|
|
cloneErr := workflow.ExecuteActivity(
|
||
|
|
ctxWithOptions,
|
||
|
|
"CloneRepoActivity",
|
||
|
|
map[string]interface{}{
|
||
|
|
"RemoteURL": in.RemoteURL,
|
||
|
|
"TargetRepoPath": in.TargetRepoPath,
|
||
|
|
},
|
||
|
|
).Get(ctx, nil)
|
||
|
|
|
||
|
|
if cloneErr != nil {
|
||
|
|
logger.Error("clone failed",
|
||
|
|
logging.Err(cloneErr),
|
||
|
|
logging.String("repo", in.TargetRepoPath))
|
||
|
|
output.LastError = fmt.Sprintf("Clone failed: %v", cloneErr)
|
||
|
|
return output, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.Info("repository cloned",
|
||
|
|
logging.String("repo", in.TargetRepoPath))
|
||
|
|
|
||
|
|
// Step 2: Read tasks from board.md
|
||
|
|
tasksToRun, err := readTasksFromBoard(in.TargetRepoPath)
|
||
|
|
if err != nil {
|
||
|
|
logger.Error("failed to read tasks",
|
||
|
|
logging.Err(err),
|
||
|
|
logging.String("repo", in.TargetRepoPath))
|
||
|
|
output.LastError = fmt.Sprintf("Failed to read tasks: %v", err)
|
||
|
|
return output, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(tasksToRun) == 0 {
|
||
|
|
logger.Warn("no tasks found in board")
|
||
|
|
output.LastError = "No tasks found in board.md"
|
||
|
|
return output, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.Info("tasks loaded",
|
||
|
|
logging.Int("count", len(tasksToRun)))
|
||
|
|
|
||
|
|
// Step 3: Process each task with recovery tracking
|
||
|
|
completedTasks := 0
|
||
|
|
failedTasks := []string{}
|
||
|
|
|
||
|
|
// LLM activity uses longer timeout and more retries
|
||
|
|
llmRetryPolicy := recovery.LLMActivityRetryPolicy()
|
||
|
|
implOptions := workflow.ActivityOptions{
|
||
|
|
StartToCloseTimeout: 30 * time.Minute,
|
||
|
|
ScheduleToCloseTimeout: 35 * time.Minute,
|
||
|
|
RetryPolicy: llmRetryPolicy.ToTemporalRetryPolicy(),
|
||
|
|
}
|
||
|
|
implCtx := workflow.WithActivityOptions(ctx, implOptions)
|
||
|
|
|
||
|
|
for taskIdx, task := range tasksToRun {
|
||
|
|
taskID := task["id"].(string)
|
||
|
|
taskDesc := task["description"].(string)
|
||
|
|
|
||
|
|
logger.Info("processing task",
|
||
|
|
logging.String("taskID", taskID),
|
||
|
|
logging.Int("index", taskIdx+1),
|
||
|
|
logging.Int("total", len(tasksToRun)))
|
||
|
|
|
||
|
|
// Add worktree
|
||
|
|
var worktreePath string
|
||
|
|
wtErr := workflow.ExecuteActivity(
|
||
|
|
ctxWithOptions,
|
||
|
|
"GitWorktreeAddActivity",
|
||
|
|
map[string]interface{}{
|
||
|
|
"RepoPath": in.TargetRepoPath,
|
||
|
|
"TaskID": taskID,
|
||
|
|
},
|
||
|
|
).Get(ctx, &worktreePath)
|
||
|
|
|
||
|
|
if wtErr != nil {
|
||
|
|
logger.Error("worktree creation failed",
|
||
|
|
logging.String("taskID", taskID),
|
||
|
|
logging.Err(wtErr))
|
||
|
|
failedTasks = append(failedTasks, taskID)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.Info("worktree created",
|
||
|
|
logging.String("taskID", taskID),
|
||
|
|
logging.String("path", worktreePath))
|
||
|
|
|
||
|
|
// Call implementer
|
||
|
|
var implOutput map[string]interface{}
|
||
|
|
implErr := workflow.ExecuteActivity(
|
||
|
|
implCtx,
|
||
|
|
"ImplementerActivity",
|
||
|
|
map[string]interface{}{
|
||
|
|
"TaskID": taskID,
|
||
|
|
"Description": taskDesc,
|
||
|
|
"WorktreePath": worktreePath,
|
||
|
|
"Prompt": PromptSpec{
|
||
|
|
TemplateRef: "implementer/default.tmpl",
|
||
|
|
Model: ModelSpec{
|
||
|
|
ModelID: in.Config.RolePrompts["implementer"].Model.ModelID,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
).Get(ctx, &implOutput)
|
||
|
|
|
||
|
|
if implErr != nil {
|
||
|
|
logger.Error("implementation failed",
|
||
|
|
logging.String("taskID", taskID),
|
||
|
|
logging.Err(implErr))
|
||
|
|
failedTasks = append(failedTasks, taskID)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.Info("implementation succeeded",
|
||
|
|
logging.String("taskID", taskID))
|
||
|
|
|
||
|
|
// Commit changes
|
||
|
|
commitErr := workflow.ExecuteActivity(
|
||
|
|
ctxWithOptions,
|
||
|
|
"GitCommitActivity",
|
||
|
|
map[string]interface{}{
|
||
|
|
"WorktreePath": worktreePath,
|
||
|
|
"Message": fmt.Sprintf("%s: implementation", taskID),
|
||
|
|
},
|
||
|
|
).Get(ctx, nil)
|
||
|
|
|
||
|
|
if commitErr != nil {
|
||
|
|
logger.Error("commit failed",
|
||
|
|
logging.String("taskID", taskID),
|
||
|
|
logging.Err(commitErr))
|
||
|
|
failedTasks = append(failedTasks, taskID)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
completedTasks++
|
||
|
|
logger.Info("task completed",
|
||
|
|
logging.String("taskID", taskID),
|
||
|
|
logging.Int("completedCount", completedTasks))
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 4: Push to remote
|
||
|
|
logger.Info("pushing changes to remote",
|
||
|
|
logging.String("repo", in.TargetRepoPath))
|
||
|
|
|
||
|
|
pushErr := workflow.ExecuteActivity(
|
||
|
|
ctxWithOptions,
|
||
|
|
"GitPushActivity",
|
||
|
|
map[string]interface{}{
|
||
|
|
"RepoPath": in.TargetRepoPath,
|
||
|
|
},
|
||
|
|
).Get(ctx, nil)
|
||
|
|
|
||
|
|
if pushErr != nil {
|
||
|
|
logger.Error("push failed",
|
||
|
|
logging.Err(pushErr))
|
||
|
|
output.LastError = fmt.Sprintf("Push failed: %v", pushErr)
|
||
|
|
return output, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.Info("changes pushed to remote")
|
||
|
|
|
||
|
|
// Step 5: Squash merge all task branches
|
||
|
|
branches := make([]string, len(tasksToRun))
|
||
|
|
for i, task := range tasksToRun {
|
||
|
|
branches[i] = fmt.Sprintf("task/%s", task["id"].(string))
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.Info("merging task branches",
|
||
|
|
logging.Int("branchCount", len(branches)))
|
||
|
|
|
||
|
|
mergeErr := workflow.ExecuteActivity(
|
||
|
|
ctxWithOptions,
|
||
|
|
"GitSquashMergeActivity",
|
||
|
|
map[string]interface{}{
|
||
|
|
"RepoPath": in.TargetRepoPath,
|
||
|
|
"Branches": branches,
|
||
|
|
"Message": fmt.Sprintf("%s: squash merge all tasks", in.Milestone),
|
||
|
|
},
|
||
|
|
).Get(ctx, nil)
|
||
|
|
|
||
|
|
if mergeErr != nil {
|
||
|
|
logger.Error("merge failed",
|
||
|
|
logging.Err(mergeErr))
|
||
|
|
output.LastError = fmt.Sprintf("Merge failed: %v", mergeErr)
|
||
|
|
return output, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.Info("workflow completed",
|
||
|
|
logging.Int("completed", completedTasks),
|
||
|
|
logging.Int("failed", len(failedTasks)))
|
||
|
|
|
||
|
|
// Success!
|
||
|
|
output.MilestoneComplete = len(failedTasks) == 0
|
||
|
|
output.Done = true
|
||
|
|
output.LastError = fmt.Sprintf("Completed %d tasks successfully, %d failed", completedTasks, len(failedTasks))
|
||
|
|
|
||
|
|
return output, nil
|
||
|
|
}
|