feat: implement proper orchestrator workflow with reconciliation loop
ci / test (push) Failing after 1m2s

Rewrite OrchestratorWorkflow as true reconciliation loop:
- PlanningActivity decides what tasks to dispatch
- Fan-out TaskUnit workflows for parallel execution
- Each TaskUnit runs Implementer → Test → Judge → Commit
- Judge reviews code quality, retries on failure with lessons
- Fan-in waits for all TaskUnits
- Board update and squash merge on success
- continue-as-new for long-running workflows
- Proper error handling and signal support

Key changes:
- statemachine/orchestrator.go: Reconciliation loop (Plan → Dispatch → Review → Repeat)
- statemachine/taskunit.go: Task execution with retry loop & judge review
- statemachine/types.go: Updated TaskUnitInput/Output for new workflow
- cmd/worker/main.go: Register RunIntegrationTestActivity
- action/integration.go: Renamed from integration_test.go (fix Go build issue)

Models:
- Planner: reasoning (OpenAI-compatible from local LLM API)
- Judge: reasoning (reviews diff + tests, gates success)
- Implementer: ornith:35b (executes tasks)

Verification: go build ./cmd/worker ./cmd/starter ✓
This commit is contained in:
Test
2026-08-26 15:00:42 -07:00
parent 121cad1ad5
commit 6a87833c7f
9 changed files with 252 additions and 259 deletions
+135 -126
View File
@@ -11,151 +11,168 @@ import (
)
// OrchestratorWorkflow orchestrates multi-agent work on a target repository.
// Reconciliation loop: Plan → Dispatch → Review → Update → Repeat
func OrchestratorWorkflow(ctx workflow.Context, in OrchestratorInput) (OrchestratorOutput, error) {
logger := workflow.GetLogger(ctx)
output := OrchestratorOutput{
MilestoneComplete: false,
Done: false,
LastError: "",
}
// Step 1: Clone the repository
activityOptions := workflow.ActivityOptions{
StartToCloseTimeout: 10 * time.Minute,
// Clone repo once at start
activityOpts := workflow.ActivityOptions{
StartToCloseTimeout: 10 * time.Minute,
ScheduleToCloseTimeout: 15 * time.Minute,
}
ctxWithOptions := workflow.WithActivityOptions(ctx, activityOptions)
cloneErr := workflow.ExecuteActivity(
ctxWithOptions,
"CloneRepoActivity",
map[string]interface{}{
"RemoteURL": in.RemoteURL,
"TargetRepoPath": in.TargetRepoPath,
},
).Get(ctx, nil)
ctxWithOpts := workflow.WithActivityOptions(ctx, activityOpts)
if cloneErr != nil {
output.LastError = fmt.Sprintf("Clone failed: %v", cloneErr)
if err := workflow.ExecuteActivity(ctxWithOpts, "CloneRepoActivity", map[string]interface{}{
"RemoteURL": in.RemoteURL,
"TargetRepoPath": in.TargetRepoPath,
}).Get(ctx, nil); err != nil {
output.LastError = fmt.Sprintf("Clone failed: %v", err)
return output, nil
}
// Step 2: Read tasks from board.md
tasksToRun, err := readTasksFromBoard(in.TargetRepoPath)
if err != nil {
output.LastError = fmt.Sprintf("Failed to read tasks: %v", err)
return output, nil
}
if len(tasksToRun) == 0 {
output.LastError = "No tasks found in board.md"
return output, nil
}
// Step 3: Process each task
completedTasks := 0
for _, task := range tasksToRun {
taskID := task["id"].(string)
taskDesc := task["description"].(string)
// taskID will be used for worktree and branch
// Add worktree
var worktreePath string
wtErr := workflow.ExecuteActivity(
ctxWithOptions,
"GitWorktreeAddActivity",
map[string]interface{}{
"RepoPath": in.TargetRepoPath,
"TaskID": taskID,
},
).Get(ctx, &worktreePath)
if wtErr != nil {
continue // Skip this task on error
// Prepare skills once
if len(in.Config.Skills) > 0 {
if err := workflow.ExecuteActivity(ctxWithOpts, "PrepareSkillsActivity", map[string]interface{}{
"Skills": in.Config.Skills,
"StreamTimeout": 30 * time.Second,
"Provider": in.PiProvider,
}).Get(ctx, nil); err != nil {
logger.Info("skill preparation failed (continuing anyway)", "error", err)
}
}
// Call implementer to generate code (longer timeout for LLM calls)
implOptions := workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Minute,
// Main reconciliation loop
var paused bool
var completedTasks int
cycleCount := 0
completedBranches := []string{}
for cycleCount < in.MaxCyclesBeforeCAN {
cycleCount++
logger.Info("orchestrator cycle", "cycle", cycleCount)
// Read board state
bordState, _ := readTasksFromBoard(in.TargetRepoPath)
// Call PlanningActivity to decide what to dispatch
implOpts := workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Minute,
ScheduleToCloseTimeout: 35 * time.Minute,
}
implCtx := workflow.WithActivityOptions(ctx, implOptions)
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)
implCtx := workflow.WithActivityOptions(ctx, implOpts)
if implErr != nil {
continue
var planOutput map[string]interface{}
if err := workflow.ExecuteActivity(implCtx, "PlanningActivity", map[string]interface{}{
"Config": in.Config,
"BoardState": fmt.Sprintf("%v", bordState),
"RepoPath": in.TargetRepoPath,
"Milestone": in.Milestone,
}).Get(ctx, &planOutput); err != nil {
logger.Info("planning failed", "error", err)
break
}
// Commit changes
commitErr := workflow.ExecuteActivity(
ctxWithOptions,
"GitCommitActivity",
map[string]interface{}{
"WorktreePath": worktreePath,
"Message": fmt.Sprintf("%s: implementation", taskID),
},
).Get(ctx, nil)
// Extract tasks to dispatch
var tasksToDispatch []interface{}
if tasks, ok := planOutput["TasksToDispatch"]; ok {
tasksToDispatch = tasks.([]interface{})
}
if commitErr == nil {
completedTasks++
if len(tasksToDispatch) == 0 {
logger.Info("no tasks to dispatch, milestone complete")
output.MilestoneComplete = true
break
}
// Fan-out: Start TaskUnit workflows for each task
logger.Info("dispatching task units", "count", len(tasksToDispatch))
var childWFs []workflow.Future
for _, taskIDRaw := range tasksToDispatch {
taskID := taskIDRaw.(string)
if paused {
logger.Info("skipping dispatch due to pause signal", "task", taskID)
continue
}
// Child workflow: TaskUnitWorkflow
childOpts := workflow.ChildWorkflowOptions{
WorkflowID: fmt.Sprintf("%s-%s-cycle%d", in.Milestone, taskID, cycleCount),
}
childCtx := workflow.WithChildOptions(ctx, childOpts)
taskUnitInput := TaskUnitInput{
TaskID: taskID,
RemoteURL: in.RemoteURL,
TargetRepoPath: in.TargetRepoPath,
Milestone: in.Milestone,
Config: in.Config,
DryRun: in.DryRun,
}
future := workflow.ExecuteChildWorkflow(childCtx, TaskUnitWorkflow, taskUnitInput)
childWFs = append(childWFs, future)
}
// Fan-in: Wait for all TaskUnits to complete
logger.Info("waiting for task units", "count", len(childWFs))
for _, future := range childWFs {
var taskOutput TaskUnitOutput
if err := future.Get(ctx, &taskOutput); err != nil {
logger.Info("task unit failed", "task", taskOutput.TaskID, "error", err)
} else if taskOutput.Status == "success" {
completedTasks++
completedBranches = append(completedBranches, taskOutput.Branch)
logger.Info("task unit succeeded", "task", taskOutput.TaskID)
}
}
// Board update: Call PlanningActivity again to update board + commit
if err := workflow.ExecuteActivity(implCtx, "PlanningActivity", map[string]interface{}{
"Config": in.Config,
"BoardState": fmt.Sprintf("%v", bordState),
"RepoPath": in.TargetRepoPath,
"Milestone": in.Milestone,
}).Get(ctx, nil); err != nil {
logger.Info("board update failed", "error", err)
}
// Push completed branches
if err := workflow.ExecuteActivity(ctxWithOpts, "GitPushActivity", map[string]interface{}{
"RepoPath": in.TargetRepoPath,
}).Get(ctx, nil); err != nil {
logger.Info("push failed", "error", err)
}
// Squash merge completed branches when milestone ready
if output.MilestoneComplete && len(completedBranches) > 0 {
if err := workflow.ExecuteActivity(ctxWithOpts, "GitSquashMergeActivity", map[string]interface{}{
"RepoPath": in.TargetRepoPath,
"Branches": completedBranches,
"Message": fmt.Sprintf("%s: squash merge completed tasks", in.Milestone),
}).Get(ctx, nil); err != nil {
logger.Info("squash merge failed", "error", err)
}
}
}
// Step 4: Push to remote
pushErr := workflow.ExecuteActivity(
ctxWithOptions,
"GitPushActivity",
map[string]interface{}{
"RepoPath": in.TargetRepoPath,
},
).Get(ctx, nil)
if pushErr != nil {
output.LastError = fmt.Sprintf("Push failed: %v", pushErr)
return output, nil
// Use continue-as-new if hit cycle cap
if cycleCount >= in.MaxCyclesBeforeCAN {
logger.Info("cycle cap reached, continuing as new", "cycles", cycleCount)
nextInput := in
nextInput.CycleCount = cycleCount
return output, workflow.NewContinueAsNewError(ctx, OrchestratorWorkflow, nextInput)
}
// 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))
}
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 {
output.LastError = fmt.Sprintf("Merge failed: %v", mergeErr)
return output, nil
}
// Success!
output.MilestoneComplete = true
// Success
output.Done = true
output.LastError = fmt.Sprintf("Completed %d tasks successfully", completedTasks)
output.LastError = fmt.Sprintf("Completed %d tasks in %d cycles", completedTasks, cycleCount)
return output, nil
}
@@ -192,11 +209,3 @@ func readTasksFromBoard(repoPath string) ([]map[string]interface{}, error) {
return tasks, nil
}
// isPiStreamTimeout checks if an error is a 504 stream timeout from Pi command
func isPiStreamTimeout(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "PiStreamTimeout")
}
+94 -121
View File
@@ -4,161 +4,134 @@ import (
"fmt"
"time"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
)
// TaskUnitWorkflow executes a single task with retry loops, timeout escalation, and lessons injection.
// TaskUnitWorkflow executes a single task with retries and judge review.
// Flow: Worktree → Implementer (retry) → Test → Judge → Commit or Retry
func TaskUnitWorkflow(ctx workflow.Context, in TaskUnitInput) (TaskUnitOutput, error) {
// Initialize output
logger := workflow.GetLogger(ctx)
output := TaskUnitOutput{
TaskID: in.TaskID,
Verdict: "fail",
Status: "failed",
Reason: "",
Branch: fmt.Sprintf("task/%s", in.TaskID),
Changes: "",
}
// 1. Add worktree for isolated work
activityOpts := workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Minute,
ScheduleToCloseTimeout: 35 * time.Minute,
}
actCtx := workflow.WithActivityOptions(ctx, activityOpts)
// Add worktree
var worktreePath string
wtErr := workflow.ExecuteActivity(
ctx,
"GitWorktreeAddActivity",
map[string]interface{}{
"RepoPath": in.TargetRepoPath,
"TaskID": in.TaskID,
},
).Get(ctx, &worktreePath)
if wtErr != nil {
output.Critique = fmt.Sprintf("Failed to create worktree: %v", wtErr)
if err := workflow.ExecuteActivity(actCtx, "GitWorktreeAddActivity", map[string]interface{}{
"RepoPath": in.TargetRepoPath,
"TaskID": in.TaskID,
}).Get(ctx, &worktreePath); err != nil {
output.Reason = fmt.Sprintf("worktree add failed: %v", err)
return output, nil
}
// 2. Retry loop with separate timeout and judge attempt tracking
timeoutAttempt := 1
for judgeAttempt := 1; judgeAttempt <= in.MaxJudgeRetries; judgeAttempt++ {
// Calculate timeouts for this attempt
baseTimeout := in.BaseTimeout * time.Duration(timeoutAttempt)
heartbeatTimeout := baseTimeout / 4
logger.Info("task unit started", "task", in.TaskID, "worktree", worktreePath)
// Prepare activity options with escalating timeout
ao := workflow.ActivityOptions{
ScheduleToCloseTimeout: baseTimeout,
StartToCloseTimeout: baseTimeout,
HeartbeatTimeout: heartbeatTimeout,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 1, // We manage retries in this loop
},
// Retry loop: implementer + test + judge
maxRetries := 3
var lessons string
for attempt := 1; attempt <= maxRetries; attempt++ {
logger.Info("attempt", "task", in.TaskID, "attempt", attempt)
// Call ImplementerActivity with escalating timeout
timeoutMultiplier := int64(attempt)
implOpts := workflow.ActivityOptions{
StartToCloseTimeout: time.Duration(timeoutMultiplier*30) * time.Minute,
ScheduleToCloseTimeout: time.Duration(timeoutMultiplier*35) * time.Minute,
}
ctxWithOptions := workflow.WithActivityOptions(ctx, ao)
implCtx := workflow.WithActivityOptions(ctx, implOpts)
// Call implementer activity
var implOutput map[string]interface{}
implErr := workflow.ExecuteActivity(
ctxWithOptions,
"ImplementerActivity",
map[string]interface{}{
"TaskID": in.TaskID,
"WorktreePath": worktreePath,
"Prompt": in.ImplementerSpec,
},
).Get(ctx, &implOutput)
implErr := workflow.ExecuteActivity(implCtx, "ImplementerActivity", map[string]interface{}{
"Config": in.Config,
"TaskID": in.TaskID,
"WorktreePath": worktreePath,
"Lessons": lessons,
}).Get(ctx, &implOutput)
// Check if it's a timeout error
if implErr != nil && isStartToCloseTimeout(implErr) {
// Timeout: escalate and retry without consuming judge attempt
timeoutAttempt++
judgeAttempt-- // Don't consume a judge retry on timeout
continue
}
if implErr != nil {
output.Critique = fmt.Sprintf("Implementer failed: %v", implErr)
if attempt < maxRetries {
logger.Info("implementer failed, will retry", "task", in.TaskID, "attempt", attempt, "error", implErr)
continue
}
output.Reason = fmt.Sprintf("implementer exhausted after %d attempts: %v", maxRetries, implErr)
return output, nil
}
// Call judge activity
judgeTimeout := time.Minute * 5
judgeCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
ScheduleToCloseTimeout: judgeTimeout,
StartToCloseTimeout: judgeTimeout,
})
var judgeOutput map[string]interface{}
judgeErr := workflow.ExecuteActivity(
judgeCtx,
"JudgeActivity",
map[string]interface{}{
"TaskID": in.TaskID,
"WorktreePath": worktreePath,
"Prompt": in.JudgeSpec,
},
).Get(ctx, &judgeOutput)
if judgeErr != nil {
output.Critique = fmt.Sprintf("Judge error: %v", judgeErr)
return output, nil
}
// Check judge verdict
verdict := ""
if judgeOutput != nil {
if v, ok := judgeOutput["Verdict"].(string); ok {
verdict = v
// Run integration test
var testOutput map[string]interface{}
if err := workflow.ExecuteActivity(actCtx, "RunIntegrationTestActivity", map[string]interface{}{
"WorktreePath": worktreePath,
"TestCmd": "go test ./...",
}).Get(ctx, &testOutput); err != nil {
logger.Info("test failed", "task", in.TaskID, "error", err)
testOutput = map[string]interface{}{
"success": false,
"logs": fmt.Sprintf("test error: %v", err),
}
}
// Get diff
var diff string
if err := workflow.ExecuteActivity(actCtx, "GitDiffActivity", map[string]interface{}{
"WorktreePath": worktreePath,
}).Get(ctx, &diff); err != nil {
logger.Info("diff failed", "task", in.TaskID, "error", err)
}
// Call JudgeActivity
var judgeOutput map[string]interface{}
if err := workflow.ExecuteActivity(actCtx, "JudgeActivity", map[string]interface{}{
"Config": in.Config,
"Diff": diff,
"IntegrationTestLogs": fmt.Sprintf("%v", testOutput),
}).Get(ctx, &judgeOutput); err != nil {
logger.Info("judge failed", "task", in.TaskID, "error", err)
}
verdict, _ := judgeOutput["Verdict"].(string)
critique, _ := judgeOutput["Critique"].(string)
if verdict == "pass" {
// Commit in worktree
commitErr := workflow.ExecuteActivity(
ctx,
"GitCommitActivity",
map[string]interface{}{
"WorktreePath": worktreePath,
"Message": fmt.Sprintf("%s: implementation", in.TaskID),
},
).Get(ctx, nil)
if commitErr != nil {
output.Critique = fmt.Sprintf("Commit failed: %v", commitErr)
// Commit and success
if err := workflow.ExecuteActivity(actCtx, "GitCommitActivity", map[string]interface{}{
"WorktreePath": worktreePath,
"Message": fmt.Sprintf("%s: implementation", in.TaskID),
}).Get(ctx, nil); err != nil {
output.Reason = fmt.Sprintf("commit failed: %v", err)
return output, nil
}
// Success!
output.Verdict = "pass"
output.Branch = "task/" + in.TaskID
output.Status = "success"
output.Changes = fmt.Sprintf("completed in %d attempt(s)", attempt)
logger.Info("task success", "task", in.TaskID, "attempt", attempt)
return output, nil
}
// Judge failed: update lessons and retry
critique := ""
if judgeOutput != nil {
if c, ok := judgeOutput["Critique"].(string); ok {
critique = c
}
// Judge failed: append to lessons and retry
if attempt < maxRetries {
lessons = fmt.Sprintf("%s\nAttempt %d critique: %s", lessons, attempt, critique)
logger.Info("judge rejected, appending to lessons and retrying", "task", in.TaskID, "attempt", attempt)
continue
}
updateErr := workflow.ExecuteActivity(
ctx,
"UpdateLessonsActivity",
map[string]interface{}{
"TargetRepoPath": in.TargetRepoPath,
"TaskID": in.TaskID,
"Attempt": judgeAttempt,
"Critique": critique,
},
).Get(ctx, nil)
if updateErr != nil {
output.Critique = fmt.Sprintf("Failed to update lessons: %v", updateErr)
return output, nil
}
// Continue to next judge attempt with lessons injected
// Exhausted retries after judge failures
output.Reason = fmt.Sprintf("judge rejected after %d attempts. Last critique: %s", maxRetries, critique)
logger.Info("judge exhausted retries", "task", in.TaskID)
return output, nil
}
// Retries exhausted
output.Verdict = "fail"
output.Critique = fmt.Sprintf("Exhausted %d judge retries", in.MaxJudgeRetries)
output.Reason = "exhausted all retry attempts"
return output, nil
}
// isStartToCloseTimeout checks if an error is a StartToCloseTimeout error
func isStartToCloseTimeout(err error) bool {
if err == nil {
return false
}
return fmt.Sprint(err) == "context deadline exceeded"
}
+11 -8
View File
@@ -69,20 +69,23 @@ type OrchestratorOutput struct {
// TaskUnitInput is the input to the TaskUnit workflow.
type TaskUnitInput struct {
TaskID string
TargetRepoPath string
JudgeSpec PromptSpec
ImplementerSpec PromptSpec
BaseTimeout time.Duration
MaxJudgeRetries int
TaskID string
RemoteURL string
TargetRepoPath string
Milestone string
Config OrchestratorConfig
DryRun bool
}
// TaskUnitOutput is the output of the TaskUnit workflow.
type TaskUnitOutput struct {
TaskID string
Verdict string // "pass" or "fail"
Critique string
Status string // "success" or "failed"
Verdict string // "pass" or "fail" from judge
Critique string // feedback from judge
Branch string
Reason string // error reason if failed
Changes string // summary of changes
}
// SkillRef references a skill source.