Files
Test 002fe98e17
ci / test (push) Failing after 5s
feat(workflows): wire TaskUnit/Orchestrator activities, add k8s deploy manifests
Implements real activity-calling logic in OrchestratorWorkflow and
TaskUnitWorkflow (previously stubs), adds GitDiffActivity, and expands
PlanningActivity's I/O to carry repo path and prior task results.

Adds k8s/ deployment manifests (worker Deployment, orchestrator Job,
Kustomize base) for the poimen-workflows Temporal worker, using a
dedicated Kubernetes namespace `poimen` and Temporal namespace
`poimen-harness` rather than sharing the Temporal server's own
`temporal`/`production` namespaces.
2026-08-21 21:57:01 -07:00

169 lines
4.6 KiB
Go

package action
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
Provider string // pi provider name (e.g. "homelab-reasoning"); required, pi has no usable default provider
}
// PrepareSkillsActivity prepares skills for use via pi command.
func PrepareSkillsActivity(ctx context.Context, in PrepareSkillsInput) error {
if in.Provider == "" {
return fmt.Errorf("PrepareSkillsInput.Provider must be set (pi has no usable default provider)")
}
for _, skill := range in.Skills {
activity.RecordHeartbeat(ctx, skill.Name)
// Run: pi clone-or-fetch <skill-url> --provider=<provider> --stream-timeout=<duration>
cmd := exec.CommandContext(ctx, "pi", "clone-or-fetch", skill.URL, "--provider="+in.Provider, 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
}