(workflow) add simple harness workflow for manual testing

This commit is contained in:
Test
2026-08-21 18:07:12 -07:00
parent 769e56d33d
commit 5b8d3df01e
17 changed files with 1080 additions and 44 deletions
+91 -1
View File
@@ -1,3 +1,93 @@
package action
// Empty stub - will be filled in T0.5
import (
"context"
"fmt"
"github.com/rockliang/poimen/workflows/action/llm"
"github.com/rockliang/poimen/workflows/prompts"
"github.com/rockliang/poimen/workflows/statemachine"
"go.temporal.io/sdk/activity"
)
// ImplementerInput is input to ImplementerActivity.
type ImplementerInput struct {
Config statemachine.OrchestratorConfig
TaskID string
WorktreePath string
Lessons string // "known errors — do not repeat" section
}
// ImplementerOutput is the output of ImplementerActivity.
type ImplementerOutput struct {
Success bool
Changes string // summary of changes made
}
// ImplementerActivity calls the Implementer LLM to implement the task.
func ImplementerActivity(ctx context.Context, in ImplementerInput) (ImplementerOutput, error) {
// Record heartbeat
activity.RecordHeartbeat(ctx, "starting implementer for "+in.TaskID)
// Get LLM client
client, err := llm.NewClient()
if err != nil {
return ImplementerOutput{}, fmt.Errorf("failed to create LLM client: %w", err)
}
// Get implementer spec
implementerSpec, exists := in.Config.RolePrompts["implementer"]
if !exists {
return ImplementerOutput{}, fmt.Errorf("implementer role prompt not configured")
}
// Build variables for template
templateVars := map[string]any{
"SystemPrompt": in.Config.SystemPrompt,
"Task": in.TaskID,
"WorktreePath": in.WorktreePath,
}
// Inject lessons if provided
if in.Lessons != "" {
templateVars["Lessons"] = in.Lessons
}
// Render template
var templateContent string
if implementerSpec.RawTemplate != "" {
templateContent = implementerSpec.RawTemplate
} else {
// Parse and render the embedded template
templateContent, err = prompts.Render(implementerSpec.TemplateRef, templateVars)
if err != nil {
return ImplementerOutput{}, fmt.Errorf("failed to render implementer template: %w", err)
}
}
// Call LLM
messages := []llm.MessageParam{
{
Role: "user",
Content: templateContent,
},
}
response, err := client.CreateMessage(ctx, llm.MessageInput{
Model: implementerSpec.Model,
SystemPrompt: in.Config.SystemPrompt,
Messages: messages,
})
if err != nil {
return ImplementerOutput{}, fmt.Errorf("implementer LLM call failed: %w", err)
}
// Record progress
activity.RecordHeartbeat(ctx, "implementer completed for "+in.TaskID)
// Return success (in full implementation would parse response and execute tool calls)
return ImplementerOutput{
Success: true,
Changes: response,
}, nil
}
+42 -1
View File
@@ -1,3 +1,44 @@
package action
// Empty stub - will be filled in later
import (
"context"
"fmt"
"os/exec"
)
// RunIntegrationTestInput is input to RunIntegrationTestActivity.
type RunIntegrationTestInput struct {
WorktreePath string
TestCmd string
}
// RunIntegrationTestOutput is the output of RunIntegrationTestActivity.
type RunIntegrationTestOutput struct {
Passed bool
Logs string
}
// RunIntegrationTestActivity runs integration tests in the worktree.
func RunIntegrationTestActivity(ctx context.Context, in RunIntegrationTestInput) (RunIntegrationTestOutput, error) {
if in.TestCmd == "" {
// No test command, assume pass
return RunIntegrationTestOutput{Passed: true, Logs: "No test command provided"}, nil
}
// Run test command
cmd := exec.CommandContext(ctx, "sh", "-c", in.TestCmd)
cmd.Dir = in.WorktreePath
output, err := cmd.CombinedOutput()
if err != nil {
return RunIntegrationTestOutput{
Passed: false,
Logs: string(output) + "\nError: " + err.Error(),
}, nil
}
return RunIntegrationTestOutput{
Passed: true,
Logs: string(output),
}, nil
}
+76 -1
View File
@@ -1,3 +1,78 @@
package action
// Empty stub - will be filled in T0.5
import (
"context"
"fmt"
"github.com/rockliang/poimen/workflows/action/llm"
"github.com/rockliang/poimen/workflows/prompts"
"github.com/rockliang/poimen/workflows/statemachine"
)
// JudgeInput is input to JudgeActivity.
type JudgeInput struct {
Config statemachine.OrchestratorConfig
Diff string // git diff output
IntegrationTestLogs string // test output
}
// JudgeOutput is the output of JudgeActivity.
type JudgeOutput struct {
Verdict string // "pass" or "fail"
Critique string // explanation if fail
}
// JudgeActivity calls the Judge LLM to review correctness.
func JudgeActivity(ctx context.Context, in JudgeInput) (JudgeOutput, error) {
// Get LLM client
client, err := llm.NewClient()
if err != nil {
return JudgeOutput{}, fmt.Errorf("failed to create LLM client: %w", err)
}
// Get judge spec
judgeSpec, exists := in.Config.RolePrompts["judge"]
if !exists {
return JudgeOutput{}, fmt.Errorf("judge role prompt not configured")
}
// Render template
var templateContent string
if judgeSpec.RawTemplate != "" {
templateContent = judgeSpec.RawTemplate
} else {
// Parse and render the embedded template
templateContent, err = prompts.Render(judgeSpec.TemplateRef, map[string]any{
"SystemPrompt": in.Config.SystemPrompt,
"Diff": in.Diff,
"TestResult": in.IntegrationTestLogs,
})
if err != nil {
return JudgeOutput{}, fmt.Errorf("failed to render judge template: %w", err)
}
}
// Call LLM
messages := []llm.MessageParam{
{
Role: "user",
Content: templateContent,
},
}
response, err := client.CreateMessage(ctx, llm.MessageInput{
Model: judgeSpec.Model,
SystemPrompt: in.Config.SystemPrompt,
Messages: messages,
})
if err != nil {
return JudgeOutput{}, fmt.Errorf("judge LLM call failed: %w", err)
}
// For now, return a default pass verdict
// In full implementation, would parse LLM response
return JudgeOutput{
Verdict: "pass",
Critique: response,
}, nil
}
+95 -1
View File
@@ -1,3 +1,97 @@
package action
// Empty stub - will be filled in later
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// Lesson represents a learned lesson from a failed attempt.
type Lesson struct {
Attempt int `json:"attempt"`
Critique string `json:"critique"`
FailedApproachSummary string `json:"failed_approach_summary"`
Timestamp string `json:"timestamp"`
}
// ReadLessonsInput is input to ReadLessonsActivity.
type ReadLessonsInput struct {
TargetRepoPath string
TaskID string
}
// ReadLessonsOutput is the output of ReadLessonsActivity.
type ReadLessonsOutput struct {
Lessons string // JSONL or formatted string of lessons
}
// ReadLessonsActivity reads lessons for a task.
func ReadLessonsActivity(ctx context.Context, in ReadLessonsInput) (ReadLessonsOutput, error) {
lessonsFile := filepath.Join(in.TargetRepoPath, "tasks", ".orchestrator", "lessons", in.TaskID+".jsonl")
// If file doesn't exist, return empty lessons
if _, err := os.Stat(lessonsFile); os.IsNotExist(err) {
return ReadLessonsOutput{Lessons: ""}, nil
}
// Read and format lessons
data, err := os.ReadFile(lessonsFile)
if err != nil {
return ReadLessonsOutput{}, fmt.Errorf("failed to read lessons file: %w", err)
}
// Format lessons as plain text
lines := string(data)
return ReadLessonsOutput{Lessons: lines}, nil
}
// UpdateLessonsInput is input to UpdateLessonsActivity.
type UpdateLessonsInput struct {
TargetRepoPath string
TaskID string
Attempt int
Critique string
FailedApproachSummary string
}
// UpdateLessonsActivity appends a lesson to the lessons file.
func UpdateLessonsActivity(ctx context.Context, in UpdateLessonsInput) error {
lessonsDir := filepath.Join(in.TargetRepoPath, "tasks", ".orchestrator", "lessons")
// Create directory if it doesn't exist
if err := os.MkdirAll(lessonsDir, 0755); err != nil {
return fmt.Errorf("failed to create lessons directory: %w", err)
}
lessonsFile := filepath.Join(lessonsDir, in.TaskID+".jsonl")
// Create lesson entry
lesson := map[string]any{
"attempt": in.Attempt,
"critique": in.Critique,
"failed_approach_summary": in.FailedApproachSummary,
"timestamp": "",
}
// Marshal to JSON
data, err := json.Marshal(lesson)
if err != nil {
return fmt.Errorf("failed to marshal lesson: %w", err)
}
// Append to file
f, err := os.OpenFile(lessonsFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open lessons file: %w", err)
}
defer f.Close()
_, err = f.Write(append(data, '\n'))
if err != nil {
return fmt.Errorf("failed to write lesson: %w", err)
}
return nil
}
+50 -1
View File
@@ -1,3 +1,52 @@
package llm
// Empty stub - will be filled in T0.5
import (
"context"
"fmt"
"os"
"github.com/rockliang/poimen/workflows/statemachine"
)
// AnthropicClient is a thin wrapper around the Anthropic API.
type AnthropicClient struct {
apiKey string
}
// NewClient creates a new AnthropicClient from the ANTHROPIC_API_KEY env var.
func NewClient() (*AnthropicClient, error) {
apiKey := os.Getenv("ANTHROPIC_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("ANTHROPIC_API_KEY environment variable not set")
}
return &AnthropicClient{
apiKey: apiKey,
}, nil
}
// MessageInput is the input to CreateMessage.
type MessageInput struct {
Model statemachine.ModelSpec
SystemPrompt string
Messages []MessageParam
}
// MessageParam represents a message parameter (simplified).
type MessageParam struct {
Role string
Content string
}
// CreateMessage calls the Anthropic API and returns the response text.
// Note: This is a stub implementation that would be fully implemented with actual API calls.
func (c *AnthropicClient) CreateMessage(ctx context.Context, in MessageInput) (string, error) {
if c.apiKey == "" {
return "", fmt.Errorf("API key not set")
}
// Placeholder implementation
// In a real implementation, this would call the Anthropic API
// For now, we return a mock response to allow testing
return fmt.Sprintf("Mock response for model %s: Processing request with %d messages", in.Model.ModelID, len(in.Messages)), nil
}
+85 -1
View File
@@ -1,3 +1,87 @@
package action
// Empty stub - will be filled in T0.5
import (
"context"
"fmt"
"github.com/rockliang/poimen/workflows/action/llm"
"github.com/rockliang/poimen/workflows/prompts"
"github.com/rockliang/poimen/workflows/statemachine"
)
// PlanningInput is input to PlanningActivity.
type PlanningInput struct {
Config statemachine.OrchestratorConfig
BoardState string // JSON or markdown of task board
Milestone string
}
// TaskDispatch represents a dispatched task.
type TaskDispatch struct {
TaskID string
PromptSpec statemachine.PromptSpec
BaseTimeout *int64 // optional override in milliseconds
}
// PlanningOutput is the output of PlanningActivity.
type PlanningOutput struct {
Tasks []TaskDispatch
SubmilestoneComplete bool
}
// PlanningActivity calls the Planner LLM to decide which tasks to dispatch.
func PlanningActivity(ctx context.Context, in PlanningInput) (PlanningOutput, error) {
// Get LLM client
client, err := llm.NewClient()
if err != nil {
return PlanningOutput{}, fmt.Errorf("failed to create LLM client: %w", err)
}
// Get planner spec
plannerSpec, exists := in.Config.RolePrompts["planner"]
if !exists {
return PlanningOutput{}, fmt.Errorf("planner role prompt not configured")
}
// Render template
var templateContent string
if plannerSpec.RawTemplate != "" {
templateContent = plannerSpec.RawTemplate
} else {
// Parse and render the embedded template
templateContent, err = prompts.Render(plannerSpec.TemplateRef, map[string]any{
"SystemPrompt": in.Config.SystemPrompt,
"BoardState": in.BoardState,
"Milestone": in.Milestone,
"Config": in.Config,
})
if err != nil {
return PlanningOutput{}, fmt.Errorf("failed to render planner template: %w", err)
}
}
// Call LLM
messages := []llm.MessageParam{
{
Role: "user",
Content: templateContent,
},
}
response, err := client.CreateMessage(ctx, llm.MessageInput{
Model: plannerSpec.Model,
SystemPrompt: in.Config.SystemPrompt,
Messages: messages,
})
if err != nil {
return PlanningOutput{}, fmt.Errorf("planner LLM call failed: %w", err)
}
// For now, return empty dispatch (will be parsed from LLM response in full implementation)
// This is a stub that allows the test to verify the activity is called
_ = response
return PlanningOutput{
Tasks: []TaskDispatch{},
SubmilestoneComplete: false,
}, nil
}
+161 -1
View File
@@ -1,3 +1,163 @@
package action
// Empty stub - will be filled in T0.4
import (
"context"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
"go.temporal.io/sdk/activity"
"github.com/rockliang/poimen/workflows/statemachine"
)
// PrepareSkillsInput is input to PrepareSkillsActivity.
type PrepareSkillsInput struct {
Skills []statemachine.SkillRef
StreamTimeout time.Duration
}
// PrepareSkillsActivity prepares skills for use via pi command.
func PrepareSkillsActivity(ctx context.Context, in PrepareSkillsInput) error {
for _, skill := range in.Skills {
activity.RecordHeartbeat(ctx, skill.Name)
// Run: pi clone-or-fetch <skill-url> --stream-timeout=<duration>
cmd := exec.CommandContext(ctx, "pi", "clone-or-fetch", skill.URL, fmt.Sprintf("--stream-timeout=%s", in.StreamTimeout.String()))
if err := cmd.Run(); err != nil {
// Classify error
classifiedErr := ClassifyPiErr(err, skill.Name)
return classifiedErr
}
}
return nil
}
// ClassifyPiErr classifies pi command errors into buckets.
// 4xx → NonRetryableApplicationError "PiClientError"
// 504 → ApplicationError "PiStreamTimeout" (retryable, but orchestrator learns and doubles timeout)
// 5xx (except 504) → generic retryable error
// Other → retryable
func ClassifyPiErr(err error, skillName string) error {
if err == nil {
return nil
}
// Try to extract HTTP status code from error message
statusCode := ExtractHTTPStatus(err)
if statusCode == 0 {
// Not an HTTP error, return as-is for generic retry
return fmt.Errorf("pi %s failed: %w", skillName, err)
}
switch {
case statusCode >= 400 && statusCode < 500:
// 4xx errors are non-retryable
return NewNonRetryableApplicationError(
fmt.Sprintf("PiClientError: status %d", statusCode),
fmt.Sprintf("pi %s returned HTTP %d", skillName, statusCode),
)
case statusCode == 504:
// 504 Gateway Timeout - stream timeout
// This is retryable, but signals the orchestrator to double the timeout
return NewApplicationError(
fmt.Sprintf("PiStreamTimeout: status %d", statusCode),
fmt.Sprintf("pi %s returned HTTP 504 (stream timeout)", skillName),
)
case statusCode >= 500:
// Other 5xx errors are retryable
return fmt.Errorf("pi %s returned HTTP %d: %w", skillName, statusCode, err)
default:
return fmt.Errorf("pi %s failed: %w", skillName, err)
}
}
// ExtractHTTPStatus tries to extract HTTP status code from error message.
func ExtractHTTPStatus(err error) int {
if err == nil {
return 0
}
errStr := err.Error()
// Try to find 3-digit numbers that could be HTTP status codes
parts := strings.Fields(errStr)
for i, part := range parts {
// Check if part is a 3-digit number (HTTP status code)
if len(part) >= 3 {
code, err := strconv.Atoi(part[:3])
if err == nil && code >= 100 && code < 600 {
return code
}
}
// Check if previous part is "status" or "HTTP"
if i > 0 {
prev := strings.ToLower(parts[i-1])
if (prev == "status" || prev == "status:") && len(part) >= 3 {
code, err := strconv.Atoi(part[:3])
if err == nil && code >= 100 && code < 600 {
return code
}
}
}
}
return 0
}
// NewNonRetryableApplicationError creates a non-retryable application error.
func NewNonRetryableApplicationError(errType, errMsg string) error {
return &NonRetryableApplicationError{
errType: errType,
errMsg: errMsg,
}
}
// NonRetryableApplicationError represents a non-retryable application error.
type NonRetryableApplicationError struct {
errType string
errMsg string
}
func (e *NonRetryableApplicationError) Error() string {
return fmt.Sprintf("%s: %s", e.errType, e.errMsg)
}
func (e *NonRetryableApplicationError) Type() string {
return e.errType
}
// NewApplicationError creates a retryable application error with a specific type.
func NewApplicationError(errType, errMsg string) error {
return &ApplicationError{
errType: errType,
errMsg: errMsg,
}
}
// ApplicationError represents a retryable application error with a type.
type ApplicationError struct {
errType string
errMsg string
}
func (e *ApplicationError) Error() string {
return fmt.Sprintf("%s: %s", e.errType, e.errMsg)
}
func (e *ApplicationError) Type() string {
return e.errType
}
// IsPiStreamTimeout checks if an error is a PiStreamTimeout error.
func IsPiStreamTimeout(err error) bool {
var appErr *ApplicationError
if errors.As(err, &appErr) {
return strings.Contains(appErr.errType, "PiStreamTimeout")
}
return false
}