feat: implement proper orchestrator workflow with reconciliation loop
ci / test (push) Failing after 1m2s
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:
+1
-1
@@ -64,7 +64,7 @@ func main() {
|
|||||||
w.RegisterActivity(action.ImplementerActivity)
|
w.RegisterActivity(action.ImplementerActivity)
|
||||||
w.RegisterActivity(action.JudgeActivity)
|
w.RegisterActivity(action.JudgeActivity)
|
||||||
// Integration and lessons activities - register when fully tested
|
// Integration and lessons activities - register when fully tested
|
||||||
// w.RegisterActivity(action.RunIntegrationTestActivity)
|
w.RegisterActivity(action.RunIntegrationTestActivity)
|
||||||
// w.RegisterActivity(action.UpdateLessonsActivity)
|
// w.RegisterActivity(action.UpdateLessonsActivity)
|
||||||
// w.RegisterActivity(action.ReadLessonsActivity)
|
// w.RegisterActivity(action.ReadLessonsActivity)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/stretchr/testify v1.12.1
|
github.com/stretchr/testify v1.12.1
|
||||||
go.temporal.io/sdk v1.48.0
|
go.temporal.io/sdk v1.48.0
|
||||||
go.uber.org/zap v1.28.0
|
go.uber.org/zap v1.28.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -38,5 +39,4 @@ require (
|
|||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
google.golang.org/grpc v1.82.1 // indirect
|
google.golang.org/grpc v1.82.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.12 // indirect
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
|||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
@@ -44,6 +48,8 @@ github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+
|
|||||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
|
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
|
||||||
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
|
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
|
||||||
|
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
|
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
@@ -131,5 +137,7 @@ google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3
|
|||||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+1
-1
@@ -9,6 +9,6 @@ metadata:
|
|||||||
app.kubernetes.io/name: poimen
|
app.kubernetes.io/name: poimen
|
||||||
app.kubernetes.io/component: orchestrator
|
app.kubernetes.io/component: orchestrator
|
||||||
data:
|
data:
|
||||||
GIT_COMMIT: "0b6bca7" # Updated automatically by CI/CD
|
GIT_COMMIT: "9fb58ae" # Updated automatically by CI/CD
|
||||||
GIT_BRANCH: "main"
|
GIT_BRANCH: "main"
|
||||||
DEPLOYMENT_DATE: "2026-08-26"
|
DEPLOYMENT_DATE: "2026-08-26"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: poimen-worker
|
app: poimen-worker
|
||||||
annotations:
|
annotations:
|
||||||
git-commit: "0b6bca7" # ✅ Updated on each push, triggers rolling restart
|
git-commit: "9fb58ae" # ✅ Updated on each push, triggers rolling restart
|
||||||
deployment-date: "2026-08-26"
|
deployment-date: "2026-08-26"
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
|
|||||||
+121
-112
@@ -11,151 +11,168 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// OrchestratorWorkflow orchestrates multi-agent work on a target repository.
|
// 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) {
|
func OrchestratorWorkflow(ctx workflow.Context, in OrchestratorInput) (OrchestratorOutput, error) {
|
||||||
|
logger := workflow.GetLogger(ctx)
|
||||||
output := OrchestratorOutput{
|
output := OrchestratorOutput{
|
||||||
MilestoneComplete: false,
|
MilestoneComplete: false,
|
||||||
Done: false,
|
Done: false,
|
||||||
LastError: "",
|
LastError: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Clone the repository
|
// Clone repo once at start
|
||||||
activityOptions := workflow.ActivityOptions{
|
activityOpts := workflow.ActivityOptions{
|
||||||
StartToCloseTimeout: 10 * time.Minute,
|
StartToCloseTimeout: 10 * time.Minute,
|
||||||
ScheduleToCloseTimeout: 15 * time.Minute,
|
ScheduleToCloseTimeout: 15 * time.Minute,
|
||||||
}
|
}
|
||||||
ctxWithOptions := workflow.WithActivityOptions(ctx, activityOptions)
|
ctxWithOpts := workflow.WithActivityOptions(ctx, activityOpts)
|
||||||
|
|
||||||
cloneErr := workflow.ExecuteActivity(
|
if err := workflow.ExecuteActivity(ctxWithOpts, "CloneRepoActivity", map[string]interface{}{
|
||||||
ctxWithOptions,
|
|
||||||
"CloneRepoActivity",
|
|
||||||
map[string]interface{}{
|
|
||||||
"RemoteURL": in.RemoteURL,
|
"RemoteURL": in.RemoteURL,
|
||||||
"TargetRepoPath": in.TargetRepoPath,
|
"TargetRepoPath": in.TargetRepoPath,
|
||||||
},
|
}).Get(ctx, nil); err != nil {
|
||||||
).Get(ctx, nil)
|
output.LastError = fmt.Sprintf("Clone failed: %v", err)
|
||||||
|
|
||||||
if cloneErr != nil {
|
|
||||||
output.LastError = fmt.Sprintf("Clone failed: %v", cloneErr)
|
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Read tasks from board.md
|
// Prepare skills once
|
||||||
tasksToRun, err := readTasksFromBoard(in.TargetRepoPath)
|
if len(in.Config.Skills) > 0 {
|
||||||
if err != nil {
|
if err := workflow.ExecuteActivity(ctxWithOpts, "PrepareSkillsActivity", map[string]interface{}{
|
||||||
output.LastError = fmt.Sprintf("Failed to read tasks: %v", err)
|
"Skills": in.Config.Skills,
|
||||||
return output, nil
|
"StreamTimeout": 30 * time.Second,
|
||||||
|
"Provider": in.PiProvider,
|
||||||
|
}).Get(ctx, nil); err != nil {
|
||||||
|
logger.Info("skill preparation failed (continuing anyway)", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tasksToRun) == 0 {
|
// Main reconciliation loop
|
||||||
output.LastError = "No tasks found in board.md"
|
var paused bool
|
||||||
return output, nil
|
var completedTasks int
|
||||||
}
|
cycleCount := 0
|
||||||
|
completedBranches := []string{}
|
||||||
|
|
||||||
// Step 3: Process each task
|
for cycleCount < in.MaxCyclesBeforeCAN {
|
||||||
completedTasks := 0
|
cycleCount++
|
||||||
for _, task := range tasksToRun {
|
logger.Info("orchestrator cycle", "cycle", cycleCount)
|
||||||
taskID := task["id"].(string)
|
|
||||||
taskDesc := task["description"].(string)
|
|
||||||
|
|
||||||
// taskID will be used for worktree and branch
|
// Read board state
|
||||||
|
bordState, _ := readTasksFromBoard(in.TargetRepoPath)
|
||||||
|
|
||||||
// Add worktree
|
// Call PlanningActivity to decide what to dispatch
|
||||||
var worktreePath string
|
implOpts := workflow.ActivityOptions{
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call implementer to generate code (longer timeout for LLM calls)
|
|
||||||
implOptions := workflow.ActivityOptions{
|
|
||||||
StartToCloseTimeout: 30 * time.Minute,
|
StartToCloseTimeout: 30 * time.Minute,
|
||||||
ScheduleToCloseTimeout: 35 * time.Minute,
|
ScheduleToCloseTimeout: 35 * time.Minute,
|
||||||
}
|
}
|
||||||
implCtx := workflow.WithActivityOptions(ctx, implOptions)
|
implCtx := workflow.WithActivityOptions(ctx, implOpts)
|
||||||
|
|
||||||
var implOutput map[string]interface{}
|
var planOutput map[string]interface{}
|
||||||
implErr := workflow.ExecuteActivity(
|
if err := workflow.ExecuteActivity(implCtx, "PlanningActivity", map[string]interface{}{
|
||||||
implCtx,
|
"Config": in.Config,
|
||||||
"ImplementerActivity",
|
"BoardState": fmt.Sprintf("%v", bordState),
|
||||||
map[string]interface{}{
|
"RepoPath": in.TargetRepoPath,
|
||||||
"TaskID": taskID,
|
"Milestone": in.Milestone,
|
||||||
"Description": taskDesc,
|
}).Get(ctx, &planOutput); err != nil {
|
||||||
"WorktreePath": worktreePath,
|
logger.Info("planning failed", "error", err)
|
||||||
"Prompt": PromptSpec{
|
break
|
||||||
TemplateRef: "implementer/default.tmpl",
|
}
|
||||||
Model: ModelSpec{
|
|
||||||
ModelID: in.Config.RolePrompts["implementer"].Model.ModelID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
).Get(ctx, &implOutput)
|
|
||||||
|
|
||||||
if implErr != nil {
|
// Extract tasks to dispatch
|
||||||
|
var tasksToDispatch []interface{}
|
||||||
|
if tasks, ok := planOutput["TasksToDispatch"]; ok {
|
||||||
|
tasksToDispatch = tasks.([]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit changes
|
// Child workflow: TaskUnitWorkflow
|
||||||
commitErr := workflow.ExecuteActivity(
|
childOpts := workflow.ChildWorkflowOptions{
|
||||||
ctxWithOptions,
|
WorkflowID: fmt.Sprintf("%s-%s-cycle%d", in.Milestone, taskID, cycleCount),
|
||||||
"GitCommitActivity",
|
}
|
||||||
map[string]interface{}{
|
childCtx := workflow.WithChildOptions(ctx, childOpts)
|
||||||
"WorktreePath": worktreePath,
|
|
||||||
"Message": fmt.Sprintf("%s: implementation", taskID),
|
|
||||||
},
|
|
||||||
).Get(ctx, nil)
|
|
||||||
|
|
||||||
if commitErr == nil {
|
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++
|
completedTasks++
|
||||||
|
completedBranches = append(completedBranches, taskOutput.Branch)
|
||||||
|
logger.Info("task unit succeeded", "task", taskOutput.TaskID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Push to remote
|
// Board update: Call PlanningActivity again to update board + commit
|
||||||
pushErr := workflow.ExecuteActivity(
|
if err := workflow.ExecuteActivity(implCtx, "PlanningActivity", map[string]interface{}{
|
||||||
ctxWithOptions,
|
"Config": in.Config,
|
||||||
"GitPushActivity",
|
"BoardState": fmt.Sprintf("%v", bordState),
|
||||||
map[string]interface{}{
|
|
||||||
"RepoPath": in.TargetRepoPath,
|
"RepoPath": in.TargetRepoPath,
|
||||||
},
|
"Milestone": in.Milestone,
|
||||||
).Get(ctx, nil)
|
}).Get(ctx, nil); err != nil {
|
||||||
|
logger.Info("board update failed", "error", err)
|
||||||
if pushErr != nil {
|
|
||||||
output.LastError = fmt.Sprintf("Push failed: %v", pushErr)
|
|
||||||
return output, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Squash merge all task branches
|
// Push completed branches
|
||||||
branches := make([]string, len(tasksToRun))
|
if err := workflow.ExecuteActivity(ctxWithOpts, "GitPushActivity", map[string]interface{}{
|
||||||
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,
|
"RepoPath": in.TargetRepoPath,
|
||||||
"Branches": branches,
|
}).Get(ctx, nil); err != nil {
|
||||||
"Message": fmt.Sprintf("%s: squash merge all tasks", in.Milestone),
|
logger.Info("push failed", "error", err)
|
||||||
},
|
|
||||||
).Get(ctx, nil)
|
|
||||||
|
|
||||||
if mergeErr != nil {
|
|
||||||
output.LastError = fmt.Sprintf("Merge failed: %v", mergeErr)
|
|
||||||
return output, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success!
|
// Squash merge completed branches when milestone ready
|
||||||
output.MilestoneComplete = true
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
output.Done = true
|
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
|
return output, nil
|
||||||
}
|
}
|
||||||
@@ -192,11 +209,3 @@ func readTasksFromBoard(repoPath string) ([]map[string]interface{}, error) {
|
|||||||
|
|
||||||
return tasks, nil
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
+83
-110
@@ -4,161 +4,134 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.temporal.io/sdk/temporal"
|
|
||||||
"go.temporal.io/sdk/workflow"
|
"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) {
|
func TaskUnitWorkflow(ctx workflow.Context, in TaskUnitInput) (TaskUnitOutput, error) {
|
||||||
// Initialize output
|
logger := workflow.GetLogger(ctx)
|
||||||
output := TaskUnitOutput{
|
output := TaskUnitOutput{
|
||||||
TaskID: in.TaskID,
|
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
|
var worktreePath string
|
||||||
wtErr := workflow.ExecuteActivity(
|
if err := workflow.ExecuteActivity(actCtx, "GitWorktreeAddActivity", map[string]interface{}{
|
||||||
ctx,
|
|
||||||
"GitWorktreeAddActivity",
|
|
||||||
map[string]interface{}{
|
|
||||||
"RepoPath": in.TargetRepoPath,
|
"RepoPath": in.TargetRepoPath,
|
||||||
"TaskID": in.TaskID,
|
"TaskID": in.TaskID,
|
||||||
},
|
}).Get(ctx, &worktreePath); err != nil {
|
||||||
).Get(ctx, &worktreePath)
|
output.Reason = fmt.Sprintf("worktree add failed: %v", err)
|
||||||
if wtErr != nil {
|
|
||||||
output.Critique = fmt.Sprintf("Failed to create worktree: %v", wtErr)
|
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Retry loop with separate timeout and judge attempt tracking
|
logger.Info("task unit started", "task", in.TaskID, "worktree", worktreePath)
|
||||||
timeoutAttempt := 1
|
|
||||||
for judgeAttempt := 1; judgeAttempt <= in.MaxJudgeRetries; judgeAttempt++ {
|
|
||||||
// Calculate timeouts for this attempt
|
|
||||||
baseTimeout := in.BaseTimeout * time.Duration(timeoutAttempt)
|
|
||||||
heartbeatTimeout := baseTimeout / 4
|
|
||||||
|
|
||||||
// Prepare activity options with escalating timeout
|
// Retry loop: implementer + test + judge
|
||||||
ao := workflow.ActivityOptions{
|
maxRetries := 3
|
||||||
ScheduleToCloseTimeout: baseTimeout,
|
var lessons string
|
||||||
StartToCloseTimeout: baseTimeout,
|
|
||||||
HeartbeatTimeout: heartbeatTimeout,
|
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||||
RetryPolicy: &temporal.RetryPolicy{
|
logger.Info("attempt", "task", in.TaskID, "attempt", attempt)
|
||||||
MaximumAttempts: 1, // We manage retries in this loop
|
|
||||||
},
|
// 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{}
|
var implOutput map[string]interface{}
|
||||||
implErr := workflow.ExecuteActivity(
|
implErr := workflow.ExecuteActivity(implCtx, "ImplementerActivity", map[string]interface{}{
|
||||||
ctxWithOptions,
|
"Config": in.Config,
|
||||||
"ImplementerActivity",
|
|
||||||
map[string]interface{}{
|
|
||||||
"TaskID": in.TaskID,
|
"TaskID": in.TaskID,
|
||||||
"WorktreePath": worktreePath,
|
"WorktreePath": worktreePath,
|
||||||
"Prompt": in.ImplementerSpec,
|
"Lessons": lessons,
|
||||||
},
|
}).Get(ctx, &implOutput)
|
||||||
).Get(ctx, &implOutput)
|
|
||||||
|
|
||||||
// Check if it's a timeout error
|
if implErr != nil {
|
||||||
if implErr != nil && isStartToCloseTimeout(implErr) {
|
if attempt < maxRetries {
|
||||||
// Timeout: escalate and retry without consuming judge attempt
|
logger.Info("implementer failed, will retry", "task", in.TaskID, "attempt", attempt, "error", implErr)
|
||||||
timeoutAttempt++
|
|
||||||
judgeAttempt-- // Don't consume a judge retry on timeout
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if implErr != nil {
|
output.Reason = fmt.Sprintf("implementer exhausted after %d attempts: %v", maxRetries, implErr)
|
||||||
output.Critique = fmt.Sprintf("Implementer failed: %v", implErr)
|
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call judge activity
|
// Run integration test
|
||||||
judgeTimeout := time.Minute * 5
|
var testOutput map[string]interface{}
|
||||||
judgeCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
|
if err := workflow.ExecuteActivity(actCtx, "RunIntegrationTestActivity", map[string]interface{}{
|
||||||
ScheduleToCloseTimeout: judgeTimeout,
|
|
||||||
StartToCloseTimeout: judgeTimeout,
|
|
||||||
})
|
|
||||||
var judgeOutput map[string]interface{}
|
|
||||||
judgeErr := workflow.ExecuteActivity(
|
|
||||||
judgeCtx,
|
|
||||||
"JudgeActivity",
|
|
||||||
map[string]interface{}{
|
|
||||||
"TaskID": in.TaskID,
|
|
||||||
"WorktreePath": worktreePath,
|
"WorktreePath": worktreePath,
|
||||||
"Prompt": in.JudgeSpec,
|
"TestCmd": "go test ./...",
|
||||||
},
|
}).Get(ctx, &testOutput); err != nil {
|
||||||
).Get(ctx, &judgeOutput)
|
logger.Info("test failed", "task", in.TaskID, "error", err)
|
||||||
if judgeErr != nil {
|
testOutput = map[string]interface{}{
|
||||||
output.Critique = fmt.Sprintf("Judge error: %v", judgeErr)
|
"success": false,
|
||||||
return output, nil
|
"logs": fmt.Sprintf("test error: %v", err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check judge verdict
|
// Get diff
|
||||||
verdict := ""
|
var diff string
|
||||||
if judgeOutput != nil {
|
if err := workflow.ExecuteActivity(actCtx, "GitDiffActivity", map[string]interface{}{
|
||||||
if v, ok := judgeOutput["Verdict"].(string); ok {
|
"WorktreePath": worktreePath,
|
||||||
verdict = v
|
}).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" {
|
if verdict == "pass" {
|
||||||
// Commit in worktree
|
// Commit and success
|
||||||
commitErr := workflow.ExecuteActivity(
|
if err := workflow.ExecuteActivity(actCtx, "GitCommitActivity", map[string]interface{}{
|
||||||
ctx,
|
|
||||||
"GitCommitActivity",
|
|
||||||
map[string]interface{}{
|
|
||||||
"WorktreePath": worktreePath,
|
"WorktreePath": worktreePath,
|
||||||
"Message": fmt.Sprintf("%s: implementation", in.TaskID),
|
"Message": fmt.Sprintf("%s: implementation", in.TaskID),
|
||||||
},
|
}).Get(ctx, nil); err != nil {
|
||||||
).Get(ctx, nil)
|
output.Reason = fmt.Sprintf("commit failed: %v", err)
|
||||||
if commitErr != nil {
|
|
||||||
output.Critique = fmt.Sprintf("Commit failed: %v", commitErr)
|
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success!
|
output.Status = "success"
|
||||||
output.Verdict = "pass"
|
output.Changes = fmt.Sprintf("completed in %d attempt(s)", attempt)
|
||||||
output.Branch = "task/" + in.TaskID
|
logger.Info("task success", "task", in.TaskID, "attempt", attempt)
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Judge failed: update lessons and retry
|
// Judge failed: append to lessons and retry
|
||||||
critique := ""
|
if attempt < maxRetries {
|
||||||
if judgeOutput != nil {
|
lessons = fmt.Sprintf("%s\nAttempt %d critique: %s", lessons, attempt, critique)
|
||||||
if c, ok := judgeOutput["Critique"].(string); ok {
|
logger.Info("judge rejected, appending to lessons and retrying", "task", in.TaskID, "attempt", attempt)
|
||||||
critique = c
|
continue
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateErr := workflow.ExecuteActivity(
|
// Exhausted retries after judge failures
|
||||||
ctx,
|
output.Reason = fmt.Sprintf("judge rejected after %d attempts. Last critique: %s", maxRetries, critique)
|
||||||
"UpdateLessonsActivity",
|
logger.Info("judge exhausted retries", "task", in.TaskID)
|
||||||
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
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue to next judge attempt with lessons injected
|
output.Reason = "exhausted all retry attempts"
|
||||||
}
|
|
||||||
|
|
||||||
// Retries exhausted
|
|
||||||
output.Verdict = "fail"
|
|
||||||
output.Critique = fmt.Sprintf("Exhausted %d judge retries", in.MaxJudgeRetries)
|
|
||||||
return output, nil
|
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"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -70,19 +70,22 @@ type OrchestratorOutput struct {
|
|||||||
// TaskUnitInput is the input to the TaskUnit workflow.
|
// TaskUnitInput is the input to the TaskUnit workflow.
|
||||||
type TaskUnitInput struct {
|
type TaskUnitInput struct {
|
||||||
TaskID string
|
TaskID string
|
||||||
|
RemoteURL string
|
||||||
TargetRepoPath string
|
TargetRepoPath string
|
||||||
JudgeSpec PromptSpec
|
Milestone string
|
||||||
ImplementerSpec PromptSpec
|
Config OrchestratorConfig
|
||||||
BaseTimeout time.Duration
|
DryRun bool
|
||||||
MaxJudgeRetries int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskUnitOutput is the output of the TaskUnit workflow.
|
// TaskUnitOutput is the output of the TaskUnit workflow.
|
||||||
type TaskUnitOutput struct {
|
type TaskUnitOutput struct {
|
||||||
TaskID string
|
TaskID string
|
||||||
Verdict string // "pass" or "fail"
|
Status string // "success" or "failed"
|
||||||
Critique string
|
Verdict string // "pass" or "fail" from judge
|
||||||
|
Critique string // feedback from judge
|
||||||
Branch string
|
Branch string
|
||||||
|
Reason string // error reason if failed
|
||||||
|
Changes string // summary of changes
|
||||||
}
|
}
|
||||||
|
|
||||||
// SkillRef references a skill source.
|
// SkillRef references a skill source.
|
||||||
|
|||||||
Reference in New Issue
Block a user