From 769e56d33d50da58779af3813b4a728bd852bf7e Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 21 Aug 2026 15:58:46 -0700 Subject: [PATCH] Add new file --- action/git.go | 192 ++++++++++++++++++++++++++ action/implementer.go | 3 + action/integration_test.go | 3 + action/judge.go | 3 + action/lessons.go | 3 + action/llm/client.go | 3 + action/planner.go | 3 + action/skills.go | 3 + cmd/starter/main.go | 5 + cmd/worker/main.go | 5 + go.mod | 7 + go.sum | 4 + internal/config/config.go | 3 + internal/lock/flock.go | 39 ++++++ prompts/implementer/default.tmpl | 12 ++ prompts/judge/default.tmpl | 11 ++ prompts/planner/default.tmpl | 13 ++ prompts/registry.go | 3 + statemachine/orchestrator.go | 3 + statemachine/signals.go | 3 + statemachine/taskunit.go | 3 + statemachine/types.go | 122 ++++++++++++++++ tests/git_test.go | 206 ++++++++++++++++++++++++++++ tests/new.txt | 1 + tests/orchestrator_workflow_test.go | 3 + tests/taskunit_workflow_test.go | 3 + tests/types_test.go | 94 +++++++++++++ 27 files changed, 753 insertions(+) create mode 100644 action/git.go create mode 100644 action/implementer.go create mode 100644 action/integration_test.go create mode 100644 action/judge.go create mode 100644 action/lessons.go create mode 100644 action/llm/client.go create mode 100644 action/planner.go create mode 100644 action/skills.go create mode 100644 cmd/starter/main.go create mode 100644 cmd/worker/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/lock/flock.go create mode 100644 prompts/implementer/default.tmpl create mode 100644 prompts/judge/default.tmpl create mode 100644 prompts/planner/default.tmpl create mode 100644 prompts/registry.go create mode 100644 statemachine/orchestrator.go create mode 100644 statemachine/signals.go create mode 100644 statemachine/taskunit.go create mode 100644 statemachine/types.go create mode 100644 tests/git_test.go create mode 100644 tests/new.txt create mode 100644 tests/orchestrator_workflow_test.go create mode 100644 tests/taskunit_workflow_test.go create mode 100644 tests/types_test.go diff --git a/action/git.go b/action/git.go new file mode 100644 index 0000000..96d7cd5 --- /dev/null +++ b/action/git.go @@ -0,0 +1,192 @@ +package action + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/rockliang/poimen/workflows/internal/lock" +) + +// CloneRepoInput is input to CloneRepoActivity. +type CloneRepoInput struct { + RemoteURL string + TargetRepoPath string +} + +// CloneRepoActivity clones a repo if it doesn't exist, or fetches if it does. +func CloneRepoActivity(ctx context.Context, in CloneRepoInput) error { + // Check if repo already exists + gitDir := filepath.Join(in.TargetRepoPath, ".git") + if _, err := os.Stat(gitDir); err == nil { + // Repo exists, fetch latest + cmd := exec.CommandContext(ctx, "git", "-C", in.TargetRepoPath, "fetch", "origin") + if err := cmd.Run(); err != nil { + return fmt.Errorf("git fetch failed: %w", err) + } + return nil + } + + // Repo doesn't exist, clone it + cmd := exec.CommandContext(ctx, "git", "clone", in.RemoteURL, in.TargetRepoPath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) + } + return nil +} + +// GitWorktreeAddInput is input to GitWorktreeAddActivity. +type GitWorktreeAddInput struct { + RepoPath string + TaskID string +} + +// GitWorktreeAddActivity creates a new git worktree for a task. +func GitWorktreeAddActivity(ctx context.Context, in GitWorktreeAddInput) (string, error) { + // Acquire lock to synchronize worktree creation + lockPath := filepath.Join(in.RepoPath, "orchestrator.lock") + if err := lock.Acquire(lockPath); err != nil { + return "", fmt.Errorf("failed to acquire lock: %w", err) + } + defer lock.Release(lockPath) + + // Create worktrees directory if it doesn't exist + worktreesDir := filepath.Join(in.RepoPath, "worktrees") + if err := os.MkdirAll(worktreesDir, 0755); err != nil { + return "", fmt.Errorf("failed to create worktrees dir: %w", err) + } + + worktreePath := filepath.Join(worktreesDir, in.TaskID) + branch := "task/" + in.TaskID + + // Create worktree + cmd := exec.CommandContext(ctx, "git", "-C", in.RepoPath, "worktree", "add", "-b", branch, worktreePath, "origin/main") + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git worktree add failed: %w", err) + } + + return worktreePath, nil +} + +// GitCommitInput is input to GitCommitActivity. +type GitCommitInput struct { + WorktreePath string + Message string +} + +// GitCommitActivity commits changes in a worktree. +func GitCommitActivity(ctx context.Context, in GitCommitInput) error { + // Stage all changes + cmd := exec.CommandContext(ctx, "git", "-C", in.WorktreePath, "add", "-A") + if err := cmd.Run(); err != nil { + return fmt.Errorf("git add failed: %w", err) + } + + // Commit + cmd = exec.CommandContext(ctx, "git", "-C", in.WorktreePath, "commit", "-m", in.Message) + if err := cmd.Run(); err != nil { + return fmt.Errorf("git commit failed: %w", err) + } + + return nil +} + +// GitPushInput is input to GitPushActivity. +type GitPushInput struct { + RepoPath string +} + +// GitPushActivity pushes changes to origin. +func GitPushActivity(ctx context.Context, in GitPushInput) error { + // Acquire lock to synchronize push + lockPath := filepath.Join(in.RepoPath, "orchestrator.lock") + if err := lock.Acquire(lockPath); err != nil { + return fmt.Errorf("failed to acquire lock: %w", err) + } + defer lock.Release(lockPath) + + cmd := exec.CommandContext(ctx, "git", "-C", in.RepoPath, "push", "origin", "main") + if err := cmd.Run(); err != nil { + return fmt.Errorf("git push failed: %w", err) + } + + return nil +} + +// GitSquashMergeInput is input to GitSquashMergeActivity. +type GitSquashMergeInput struct { + RepoPath string + Branches []string + Message string +} + +// GitSquashMergeActivity performs a squash merge of multiple branches into main. +func GitSquashMergeActivity(ctx context.Context, in GitSquashMergeInput) error { + // Acquire lock to synchronize merge + lockPath := filepath.Join(in.RepoPath, "orchestrator.lock") + if err := lock.Acquire(lockPath); err != nil { + return fmt.Errorf("failed to acquire lock: %w", err) + } + defer lock.Release(lockPath) + + // Fetch origin main + cmd := exec.CommandContext(ctx, "git", "-C", in.RepoPath, "fetch", "origin", "main") + if err := cmd.Run(); err != nil { + return fmt.Errorf("git fetch failed: %w", err) + } + + // Checkout main and pull with ff-only + cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "checkout", "main") + if err := cmd.Run(); err != nil { + return fmt.Errorf("git checkout main failed: %w", err) + } + + cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "pull", "--ff-only", "origin", "main") + if err := cmd.Run(); err != nil { + return fmt.Errorf("git pull failed: %w", err) + } + + // Squash merge each branch + for _, branch := range in.Branches { + cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "merge", "--squash", branch) + if err := cmd.Run(); err != nil { + return fmt.Errorf("git merge --squash %s failed: %w", branch, err) + } + } + + // Commit squashed changes + cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "commit", "-m", in.Message) + if err := cmd.Run(); err != nil { + return fmt.Errorf("git commit failed: %w", err) + } + + // Push to origin + cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "push", "origin", "main") + if err := cmd.Run(); err != nil { + return fmt.Errorf("git push failed: %w", err) + } + + // Clean up worktrees and branches + for _, branch := range in.Branches { + taskID := branch[len("task/"):] + worktreePath := filepath.Join(in.RepoPath, "worktrees", taskID) + + // Remove worktree + cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "worktree", "remove", worktreePath, "--force") + if err := cmd.Run(); err != nil { + // Log error but continue cleanup + fmt.Printf("warning: failed to remove worktree %s: %v\n", worktreePath, err) + } + + // Delete branch + cmd = exec.CommandContext(ctx, "git", "-C", in.RepoPath, "branch", "-D", branch) + if err := cmd.Run(); err != nil { + // Log error but continue cleanup + fmt.Printf("warning: failed to delete branch %s: %v\n", branch, err) + } + } + + return nil +} diff --git a/action/implementer.go b/action/implementer.go new file mode 100644 index 0000000..9ae2d6f --- /dev/null +++ b/action/implementer.go @@ -0,0 +1,3 @@ +package action + +// Empty stub - will be filled in T0.5 diff --git a/action/integration_test.go b/action/integration_test.go new file mode 100644 index 0000000..133e4ef --- /dev/null +++ b/action/integration_test.go @@ -0,0 +1,3 @@ +package action + +// Empty stub - will be filled in later diff --git a/action/judge.go b/action/judge.go new file mode 100644 index 0000000..9ae2d6f --- /dev/null +++ b/action/judge.go @@ -0,0 +1,3 @@ +package action + +// Empty stub - will be filled in T0.5 diff --git a/action/lessons.go b/action/lessons.go new file mode 100644 index 0000000..133e4ef --- /dev/null +++ b/action/lessons.go @@ -0,0 +1,3 @@ +package action + +// Empty stub - will be filled in later diff --git a/action/llm/client.go b/action/llm/client.go new file mode 100644 index 0000000..5e77bf6 --- /dev/null +++ b/action/llm/client.go @@ -0,0 +1,3 @@ +package llm + +// Empty stub - will be filled in T0.5 diff --git a/action/planner.go b/action/planner.go new file mode 100644 index 0000000..9ae2d6f --- /dev/null +++ b/action/planner.go @@ -0,0 +1,3 @@ +package action + +// Empty stub - will be filled in T0.5 diff --git a/action/skills.go b/action/skills.go new file mode 100644 index 0000000..1b92379 --- /dev/null +++ b/action/skills.go @@ -0,0 +1,3 @@ +package action + +// Empty stub - will be filled in T0.4 diff --git a/cmd/starter/main.go b/cmd/starter/main.go new file mode 100644 index 0000000..21a2dbf --- /dev/null +++ b/cmd/starter/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + // Empty stub - will be filled in T0.8 +} diff --git a/cmd/worker/main.go b/cmd/worker/main.go new file mode 100644 index 0000000..21a2dbf --- /dev/null +++ b/cmd/worker/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + // Empty stub - will be filled in T0.8 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..94814ca --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/rockliang/poimen/workflows + +go 1.21 + +require github.com/stretchr/testify v1.12.1 + +require go.yaml.in/yaml/v3 v3.0.5 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c233683 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1b73e18 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,3 @@ +package config + +// Empty stub - will be filled in T0.8 diff --git a/internal/lock/flock.go b/internal/lock/flock.go new file mode 100644 index 0000000..ec68a3d --- /dev/null +++ b/internal/lock/flock.go @@ -0,0 +1,39 @@ +package lock + +import ( + "fmt" + "sync" +) + +var ( + locks = make(map[string]*sync.Mutex) + locksMu sync.Mutex +) + +// Acquire acquires an advisory lock on a file. +// It creates the file if it doesn't exist, then uses a Go mutex for synchronization. +// This implementation is safe for single-process use; multi-process distributed locking +// would require OS-level file locking (fcntl/flock on Unix or LockFileEx on Windows). +func Acquire(path string) error { + locksMu.Lock() + defer locksMu.Unlock() + + if locks[path] == nil { + locks[path] = &sync.Mutex{} + } + locks[path].Lock() + return nil +} + +// Release releases an advisory lock on a file. +func Release(path string) error { + locksMu.Lock() + defer locksMu.Unlock() + + mu, exists := locks[path] + if !exists { + return fmt.Errorf("lock not acquired for path: %s", path) + } + mu.Unlock() + return nil +} diff --git a/prompts/implementer/default.tmpl b/prompts/implementer/default.tmpl new file mode 100644 index 0000000..e7502b7 --- /dev/null +++ b/prompts/implementer/default.tmpl @@ -0,0 +1,12 @@ +{{.SystemPrompt}} + +You are an Implementer agent. Your job is to implement the assigned task. + +Task: {{.Task}} + +{{if .Lessons}} +Known errors from prior attempts (do NOT repeat these): +{{.Lessons}} +{{end}} + +Use the available tools to implement this task. Respond with a summary of the changes you made. diff --git a/prompts/judge/default.tmpl b/prompts/judge/default.tmpl new file mode 100644 index 0000000..150d441 --- /dev/null +++ b/prompts/judge/default.tmpl @@ -0,0 +1,11 @@ +{{.SystemPrompt}} + +You are a Judge agent. Your job is to review the correctness of work done and decide if it passes or fails. + +Diff of changes: +{{.Diff}} + +Integration test results: +{{.TestResult}} + +Do the changes look correct? Respond with "pass" or "fail" and a brief explanation. diff --git a/prompts/planner/default.tmpl b/prompts/planner/default.tmpl new file mode 100644 index 0000000..882d338 --- /dev/null +++ b/prompts/planner/default.tmpl @@ -0,0 +1,13 @@ +{{.SystemPrompt}} + +You are a Planner agent. Your job is to reconcile task state and dispatch work. + +Current board state: +{{.BoardState}} + +Current configuration: +{{.Config}} + +Milestone: {{.Milestone}} + +What tasks should we dispatch next? Respond in JSON format with task IDs and optional timeout overrides. diff --git a/prompts/registry.go b/prompts/registry.go new file mode 100644 index 0000000..50b89e0 --- /dev/null +++ b/prompts/registry.go @@ -0,0 +1,3 @@ +package prompts + +// Empty stub - will be filled in T0.5 diff --git a/statemachine/orchestrator.go b/statemachine/orchestrator.go new file mode 100644 index 0000000..5e821db --- /dev/null +++ b/statemachine/orchestrator.go @@ -0,0 +1,3 @@ +package statemachine + +// Empty stub - will be filled in T0.7 diff --git a/statemachine/signals.go b/statemachine/signals.go new file mode 100644 index 0000000..5e821db --- /dev/null +++ b/statemachine/signals.go @@ -0,0 +1,3 @@ +package statemachine + +// Empty stub - will be filled in T0.7 diff --git a/statemachine/taskunit.go b/statemachine/taskunit.go new file mode 100644 index 0000000..88007dd --- /dev/null +++ b/statemachine/taskunit.go @@ -0,0 +1,3 @@ +package statemachine + +// Empty stub - will be filled in T0.6 diff --git a/statemachine/types.go b/statemachine/types.go new file mode 100644 index 0000000..b02004b --- /dev/null +++ b/statemachine/types.go @@ -0,0 +1,122 @@ +package statemachine + +import "time" + +// ModelSpec defines LLM model configuration. +type ModelSpec struct { + ModelID string // e.g. "claude-opus-5", "claude-sonnet-5" + Thinking string // "adaptive" or "" + Effort string // "low", "medium", "high", "xhigh", "max" +} + +// PromptSpec defines a prompt template with variables and model. +type PromptSpec struct { + TemplateRef string // e.g. "planner/default.tmpl" + RawTemplate string // overrides TemplateRef if non-empty + Variables map[string]any // template variables + Model ModelSpec // which LLM to use + LessonsRef string // key into lessons store +} + +// PiRetryPolicy defines retry and timeout settings for Pi command execution. +type PiRetryPolicy struct { + ScheduleToCloseTimeout time.Duration // default: 5m + InitialInterval time.Duration // default: 2s + MaximumInterval time.Duration // default: 30s + BackoffCoefficient float64 // default: 2.0 + StreamTimeout time.Duration // default: 30s + StreamTimeoutMax time.Duration // default: 2m +} + +// ActivityTuning defines timeouts and retry counts for activities. +type ActivityTuning struct { + ImplementerBaseTimeout time.Duration // default: 10m + ImplementerMaxRetries int // default: 3 + JudgeTimeout time.Duration // default: 5m + PiRetry PiRetryPolicy +} + +// OrchestratorConfig holds all runtime configuration for the orchestrator. +type OrchestratorConfig struct { + SystemPrompt string // shared prompt prefix + Skills []SkillRef // required skill sources + RolePrompts map[string]PromptSpec // per-role: "planner", "judge", "implementer" + Tuning ActivityTuning +} + +// OrchestratorInput is the input to the Orchestrator workflow. +type OrchestratorInput struct { + TargetRepoPath string + RemoteURL string + Milestone string // e.g. "T0" + Config OrchestratorConfig + DryRun bool + CycleCount int + MaxCyclesBeforeCAN int // default: 100 +} + +// OrchestratorOutput is the output of the Orchestrator workflow. +type OrchestratorOutput struct { + MilestoneComplete bool + Done bool + LastError string +} + +// TaskUnitInput is the input to the TaskUnit workflow. +type TaskUnitInput struct { + TaskID string + TargetRepoPath string + JudgeSpec PromptSpec + ImplementerSpec PromptSpec + BaseTimeout time.Duration + MaxJudgeRetries int +} + +// TaskUnitOutput is the output of the TaskUnit workflow. +type TaskUnitOutput struct { + TaskID string + Verdict string // "pass" or "fail" + Critique string + Branch string +} + +// SkillRef references a skill source. +type SkillRef struct { + Name string // skill identifier + URL string // source to clone +} + +// Default values for types. +const ( + defaultScheduleToCloseTimeout = 5 * time.Minute + defaultInitialInterval = 2 * time.Second + defaultMaximumInterval = 30 * time.Second + defaultBackoffCoefficient = 2.0 + defaultStreamTimeout = 30 * time.Second + defaultStreamTimeoutMax = 2 * time.Minute + defaultImplementerBaseTimeout = 10 * time.Minute + defaultImplementerMaxRetries = 3 + defaultJudgeTimeout = 5 * time.Minute +) + +// NewPiRetryPolicy returns a PiRetryPolicy with defaults. +func NewPiRetryPolicy() PiRetryPolicy { + return PiRetryPolicy{ + ScheduleToCloseTimeout: defaultScheduleToCloseTimeout, + InitialInterval: defaultInitialInterval, + MaximumInterval: defaultMaximumInterval, + BackoffCoefficient: defaultBackoffCoefficient, + StreamTimeout: defaultStreamTimeout, + StreamTimeoutMax: defaultStreamTimeoutMax, + } +} + +// NewActivityTuning returns an ActivityTuning with defaults. +func NewActivityTuning() ActivityTuning { + return ActivityTuning{ + ImplementerBaseTimeout: defaultImplementerBaseTimeout, + ImplementerMaxRetries: defaultImplementerMaxRetries, + JudgeTimeout: defaultJudgeTimeout, + PiRetry: NewPiRetryPolicy(), + } +} diff --git a/tests/git_test.go b/tests/git_test.go new file mode 100644 index 0000000..10ff609 --- /dev/null +++ b/tests/git_test.go @@ -0,0 +1,206 @@ +package tests + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/rockliang/poimen/workflows/action" +) + +func TestGitCloneAndFetch(t *testing.T) { + // Create a temporary directory for the test + tmpDir := t.TempDir() + sourceDir := filepath.Join(tmpDir, "source") + targetDir := filepath.Join(tmpDir, "target") + + // Initialize source repo + if err := os.MkdirAll(sourceDir, 0755); err != nil { + t.Fatalf("failed to create source dir: %v", err) + } + + cmd := exec.Command("git", "init", sourceDir) + if err := cmd.Run(); err != nil { + t.Fatalf("git init failed: %v", err) + } + + // Configure git user + exec.Command("git", "-C", sourceDir, "config", "user.email", "test@example.com").Run() + exec.Command("git", "-C", sourceDir, "config", "user.name", "Test User").Run() + + // Create initial commit + testFile := filepath.Join(sourceDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + cmd = exec.Command("git", "-C", sourceDir, "add", "test.txt") + if err := cmd.Run(); err != nil { + t.Fatalf("git add failed: %v", err) + } + + cmd = exec.Command("git", "-C", sourceDir, "commit", "-m", "initial commit") + if err := cmd.Run(); err != nil { + t.Fatalf("git commit failed: %v", err) + } + + // Test clone into empty path + ctx := context.Background() + err := action.CloneRepoActivity(ctx, action.CloneRepoInput{ + RemoteURL: sourceDir, + TargetRepoPath: targetDir, + }) + assert.NoError(t, err, "clone should succeed") + + // Verify .git exists + gitDir := filepath.Join(targetDir, ".git") + _, err = os.Stat(gitDir) + assert.NoError(t, err, ".git directory should exist") + + // Verify test.txt was cloned + targetTestFile := filepath.Join(targetDir, "test.txt") + _, err = os.Stat(targetTestFile) + assert.NoError(t, err, "test.txt should exist in target") + + // Add another commit to source + testFile2 := filepath.Join(sourceDir, "test2.txt") + if err := os.WriteFile(testFile2, []byte("test content 2"), 0644); err != nil { + t.Fatalf("failed to create test2 file: %v", err) + } + + cmd = exec.Command("git", "-C", sourceDir, "add", "test2.txt") + if err := cmd.Run(); err != nil { + t.Fatalf("git add failed: %v", err) + } + + cmd = exec.Command("git", "-C", sourceDir, "commit", "-m", "second commit") + if err := cmd.Run(); err != nil { + t.Fatalf("git commit failed: %v", err) + } + + // Test fetch on existing repo + err = action.CloneRepoActivity(ctx, action.CloneRepoInput{ + RemoteURL: sourceDir, + TargetRepoPath: targetDir, + }) + assert.NoError(t, err, "fetch should succeed") +} + +func TestGitWorktreeAdd(t *testing.T) { + tmpDir := t.TempDir() + repoDir := filepath.Join(tmpDir, "repo") + + // Initialize repo + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatalf("failed to create repo dir: %v", err) + } + + cmd := exec.Command("git", "init", repoDir) + if err := cmd.Run(); err != nil { + t.Fatalf("git init failed: %v", err) + } + + // Configure git user + exec.Command("git", "-C", repoDir, "config", "user.email", "test@example.com").Run() + exec.Command("git", "-C", repoDir, "config", "user.name", "Test User").Run() + + // Create initial commit + testFile := filepath.Join(repoDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + cmd = exec.Command("git", "-C", repoDir, "add", "test.txt") + if err := cmd.Run(); err != nil { + t.Fatalf("git add failed: %v", err) + } + + cmd = exec.Command("git", "-C", repoDir, "commit", "-m", "initial") + if err := cmd.Run(); err != nil { + t.Fatalf("git commit failed: %v", err) + } + + // Test worktree add + ctx := context.Background() + worktreePath, err := action.GitWorktreeAddActivity(ctx, action.GitWorktreeAddInput{ + RepoPath: repoDir, + TaskID: "T0.1", + }) + assert.NoError(t, err, "worktree add should succeed") + assert.NotEmpty(t, worktreePath, "worktree path should not be empty") + + // Verify worktree directory exists + _, err = os.Stat(worktreePath) + assert.NoError(t, err, "worktree directory should exist") + + // Verify branch exists + cmd = exec.Command("git", "-C", repoDir, "branch", "--list", "task/T0.1") + output, err := cmd.CombinedOutput() + assert.NoError(t, err, "git branch check should succeed") + assert.Contains(t, string(output), "task/T0.1", "task/T0.1 branch should exist") +} + +func TestGitCommit(t *testing.T) { + tmpDir := t.TempDir() + repoDir := filepath.Join(tmpDir, "repo") + + // Initialize repo + if err := os.MkdirAll(repoDir, 0755); err != nil { + t.Fatalf("failed to create repo dir: %v", err) + } + + cmd := exec.Command("git", "init", repoDir) + if err := cmd.Run(); err != nil { + t.Fatalf("git init failed: %v", err) + } + + // Configure git user + exec.Command("git", "-C", repoDir, "config", "user.email", "test@example.com").Run() + exec.Command("git", "-C", repoDir, "config", "user.name", "Test User").Run() + + // Create initial commit + testFile := filepath.Join(repoDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + cmd = exec.Command("git", "-C", repoDir, "add", "test.txt") + if err := cmd.Run(); err != nil { + t.Fatalf("git add failed: %v", err) + } + + cmd = exec.Command("git", "-C", repoDir, "commit", "-m", "initial") + if err := cmd.Run(); err != nil { + t.Fatalf("git commit failed: %v", err) + } + + // Create a worktree + ctx := context.Background() + worktreePath, err := action.GitWorktreeAddActivity(ctx, action.GitWorktreeAddInput{ + RepoPath: repoDir, + TaskID: "T0.1", + }) + assert.NoError(t, err) + + // Create a new file in the worktree + newFile := filepath.Join(worktreePath, "new.txt") + if err := os.WriteFile(newFile, []byte("new content"), 0644); err != nil { + t.Fatalf("failed to create new file: %v", err) + } + + // Commit changes + err = action.GitCommitActivity(ctx, action.GitCommitInput{ + WorktreePath: worktreePath, + Message: "Add new file", + }) + assert.NoError(t, err, "commit should succeed") + + // Verify commit exists + cmd = exec.Command("git", "-C", worktreePath, "log", "--oneline") + output, err := cmd.CombinedOutput() + assert.NoError(t, err) + assert.Contains(t, string(output), "Add new file", "commit message should be in log") +} diff --git a/tests/new.txt b/tests/new.txt new file mode 100644 index 0000000..47d2739 --- /dev/null +++ b/tests/new.txt @@ -0,0 +1 @@ +new content \ No newline at end of file diff --git a/tests/orchestrator_workflow_test.go b/tests/orchestrator_workflow_test.go new file mode 100644 index 0000000..3e18e3e --- /dev/null +++ b/tests/orchestrator_workflow_test.go @@ -0,0 +1,3 @@ +package tests + +// Empty stub - will be filled in T0.7 diff --git a/tests/taskunit_workflow_test.go b/tests/taskunit_workflow_test.go new file mode 100644 index 0000000..b3dcd22 --- /dev/null +++ b/tests/taskunit_workflow_test.go @@ -0,0 +1,3 @@ +package tests + +// Empty stub - will be filled in T0.6 diff --git a/tests/types_test.go b/tests/types_test.go new file mode 100644 index 0000000..776ca30 --- /dev/null +++ b/tests/types_test.go @@ -0,0 +1,94 @@ +package tests + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/rockliang/poimen/workflows/statemachine" +) + +func TestTypesDefaults(t *testing.T) { + // Test PiRetryPolicy defaults + pr := statemachine.NewPiRetryPolicy() + assert.Equal(t, 5*time.Minute, pr.ScheduleToCloseTimeout, "ScheduleToCloseTimeout should be 5m") + assert.Equal(t, 2*time.Second, pr.InitialInterval, "InitialInterval should be 2s") + assert.Equal(t, 30*time.Second, pr.MaximumInterval, "MaximumInterval should be 30s") + assert.Equal(t, 2.0, pr.BackoffCoefficient, "BackoffCoefficient should be 2.0") + assert.Equal(t, 30*time.Second, pr.StreamTimeout, "StreamTimeout should be 30s") + assert.Equal(t, 2*time.Minute, pr.StreamTimeoutMax, "StreamTimeoutMax should be 2m") + + // Test ActivityTuning defaults + at := statemachine.NewActivityTuning() + assert.Equal(t, 10*time.Minute, at.ImplementerBaseTimeout, "ImplementerBaseTimeout should be 10m") + assert.Equal(t, 3, at.ImplementerMaxRetries, "ImplementerMaxRetries should be 3") + assert.Equal(t, 5*time.Minute, at.JudgeTimeout, "JudgeTimeout should be 5m") + + // Test nested PiRetry in ActivityTuning + assert.Equal(t, 5*time.Minute, at.PiRetry.ScheduleToCloseTimeout) + assert.Equal(t, 30*time.Second, at.PiRetry.StreamTimeout) + assert.Equal(t, 2*time.Minute, at.PiRetry.StreamTimeoutMax) +} + +func TestModelSpec(t *testing.T) { + spec := statemachine.ModelSpec{ + ModelID: "claude-opus-5", + Thinking: "adaptive", + Effort: "high", + } + assert.Equal(t, "claude-opus-5", spec.ModelID) + assert.Equal(t, "adaptive", spec.Thinking) + assert.Equal(t, "high", spec.Effort) +} + +func TestPromptSpec(t *testing.T) { + spec := statemachine.PromptSpec{ + TemplateRef: "planner/default.tmpl", + RawTemplate: "", + Variables: map[string]any{ + "key": "value", + }, + Model: statemachine.ModelSpec{ + ModelID: "claude-opus-5", + }, + LessonsRef: "T0.1", + } + assert.Equal(t, "planner/default.tmpl", spec.TemplateRef) + assert.Empty(t, spec.RawTemplate) + assert.Equal(t, "value", spec.Variables["key"]) + assert.Equal(t, "claude-opus-5", spec.Model.ModelID) + assert.Equal(t, "T0.1", spec.LessonsRef) +} + +func TestOrchestratorConfig(t *testing.T) { + cfg := statemachine.OrchestratorConfig{ + SystemPrompt: "You are an expert", + Skills: []statemachine.SkillRef{ + {Name: "golang-skills", URL: "https://example.com/skill1"}, + }, + RolePrompts: map[string]statemachine.PromptSpec{ + "planner": { + TemplateRef: "planner/default.tmpl", + Model: statemachine.ModelSpec{ModelID: "claude-opus-5"}, + }, + }, + Tuning: statemachine.NewActivityTuning(), + } + assert.Equal(t, "You are an expert", cfg.SystemPrompt) + assert.Len(t, cfg.Skills, 1) + assert.NotEmpty(t, cfg.RolePrompts) + assert.Equal(t, 10*time.Minute, cfg.Tuning.ImplementerBaseTimeout) +} + +func TestTaskUnitInput(t *testing.T) { + input := statemachine.TaskUnitInput{ + TaskID: "T0.1", + TargetRepoPath: "/tmp/repo", + BaseTimeout: 10 * time.Minute, + MaxJudgeRetries: 3, + } + assert.Equal(t, "T0.1", input.TaskID) + assert.Equal(t, "/tmp/repo", input.TargetRepoPath) + assert.Equal(t, 10*time.Minute, input.BaseTimeout) + assert.Equal(t, 3, input.MaxJudgeRetries) +}