refactor(monorepo): extract inbox and orch runtimes
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
orchcli "ai-workflow-skill/packages/orch-runtime/internal/cli/orch"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(orchcli.Execute(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
module ai-workflow-skill/packages/orch-runtime
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.1
|
||||
)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type answerOptions struct {
|
||||
runID string
|
||||
taskID string
|
||||
body string
|
||||
bodyFile string
|
||||
payloadJSON string
|
||||
}
|
||||
|
||||
func newAnswerCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &answerOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "answer",
|
||||
Short: "Answer the active blocked question for a task",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
body, err := resolveBodyValue(opts.body, opts.bodyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
result, err := store.NewOrchStore(sqlDB).AnswerTask(ctx, store.AnswerInput{
|
||||
RunID: opts.runID,
|
||||
TaskID: opts.taskID,
|
||||
Body: body,
|
||||
PayloadJSON: opts.payloadJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "answer",
|
||||
Data: map[string]any{
|
||||
"task": result.Task,
|
||||
"attempt": result.Attempt,
|
||||
"thread": result.Thread,
|
||||
"message": result.Message,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "answered task %s on thread %s\n", result.Task.TaskID, result.Thread.ThreadID)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID")
|
||||
cmd.Flags().StringVar(&opts.body, "body", "", "Answer body")
|
||||
cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read answer body from file")
|
||||
cmd.Flags().StringVar(&opts.payloadJSON, "payload-json", "", "Structured payload JSON string")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
_ = cmd.MarkFlagRequired("task")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type blockedOptions struct {
|
||||
runID string
|
||||
}
|
||||
|
||||
func newBlockedCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &blockedOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "blocked",
|
||||
Short: "List blocked tasks and their latest question",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
blocked, err := store.NewOrchStore(sqlDB).ListBlockedTasks(ctx, opts.runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "blocked",
|
||||
Data: map[string]any{
|
||||
"blocked": blocked,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
if len(blocked) == 0 {
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), "no blocked tasks")
|
||||
return err
|
||||
}
|
||||
for _, item := range blocked {
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\n", item.Task.TaskID, item.Question.Summary); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
)
|
||||
|
||||
func resolveBodyValue(body, bodyFile string) (string, error) {
|
||||
if body != "" && bodyFile != "" {
|
||||
return "", protocol.InvalidInput("body and body-file are mutually exclusive", nil)
|
||||
}
|
||||
if bodyFile == "" {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(bodyFile)
|
||||
if err != nil {
|
||||
return "", protocol.InvalidInput("failed to read body-file", err)
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type cancelOptions struct {
|
||||
runID string
|
||||
taskID string
|
||||
reason string
|
||||
}
|
||||
|
||||
func newCancelCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &cancelOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cancel",
|
||||
Short: "Cancel a task or an entire run",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
result, err := store.NewOrchStore(sqlDB).Cancel(ctx, store.CancelControlInput{
|
||||
RunID: opts.runID,
|
||||
TaskID: opts.taskID,
|
||||
Reason: opts.reason,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "cancel",
|
||||
Data: map[string]any{
|
||||
"run": result.Run,
|
||||
"cancelled_tasks": result.CancelledTasks,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
if opts.taskID != "" {
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "cancelled task %s in run %s\n", opts.taskID, opts.runID)
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "cancelled run %s (%d tasks)\n", opts.runID, len(result.CancelledTasks))
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().StringVar(&opts.taskID, "task", "", "Optional task ID")
|
||||
cmd.Flags().StringVar(&opts.reason, "reason", "", "Cancellation reason")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type cleanupOptions struct {
|
||||
runID string
|
||||
taskID string
|
||||
attemptNo int
|
||||
allCompleted bool
|
||||
force bool
|
||||
}
|
||||
|
||||
func newCleanupCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &cleanupOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cleanup",
|
||||
Short: "Remove completed or abandoned attempt worktrees",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
s := store.NewOrchStore(sqlDB)
|
||||
candidates, err := s.ListCleanupCandidates(ctx, store.CleanupInput{
|
||||
RunID: opts.runID,
|
||||
TaskID: opts.taskID,
|
||||
AttemptNo: opts.attemptNo,
|
||||
AllCompleted: opts.allCompleted,
|
||||
Force: opts.force,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := make([]store.CleanupRecord, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if err := cleanupAttemptWorktree(ctx, candidate.Attempt, opts.force); err != nil {
|
||||
return err
|
||||
}
|
||||
records = append(records, store.CleanupRecord{Attempt: candidate.Attempt})
|
||||
}
|
||||
|
||||
cleaned, err := s.MarkAttemptsCleaned(ctx, records)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "cleanup",
|
||||
Data: map[string]any{
|
||||
"cleaned": cleaned,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "cleaned %d worktrees\n", len(cleaned))
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().StringVar(&opts.taskID, "task", "", "Optional task ID")
|
||||
cmd.Flags().IntVar(&opts.attemptNo, "attempt", 0, "Specific attempt number")
|
||||
cmd.Flags().BoolVar(&opts.allCompleted, "all-completed", false, "Clean all completed or abandoned worktrees in the run")
|
||||
cmd.Flags().BoolVar(&opts.force, "force", false, "Force cleanup even for non-terminal worktrees")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOrchRunShowReturnsRunSummaryAndTaskCounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_001",
|
||||
"--goal", "Build blog MVP",
|
||||
"--summary", "Public blog plus admin CRUD",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_001",
|
||||
"--task", "T1",
|
||||
"--title", "Implement retry policy",
|
||||
"--summary", "Add retry policy to HTTP client",
|
||||
"--default-to", "worker-a",
|
||||
)
|
||||
|
||||
showOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "show",
|
||||
"--run", "run_blog_001",
|
||||
)
|
||||
|
||||
var showResp map[string]any
|
||||
mustDecodeJSON(t, showOut, &showResp)
|
||||
if got := nestedString(t, showResp, "data", "run", "run_id"); got != "run_blog_001" {
|
||||
t.Fatalf("expected run id run_blog_001, got %q", got)
|
||||
}
|
||||
if got := nestedString(t, showResp, "data", "run", "status"); got != "ready" {
|
||||
t.Fatalf("expected run status ready, got %q", got)
|
||||
}
|
||||
|
||||
taskCounts, ok := nestedValue(t, showResp, "data", "task_counts").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected task_counts object, got %#v", nestedValue(t, showResp, "data", "task_counts"))
|
||||
}
|
||||
if got, _ := taskCounts["ready"].(float64); got < 1 {
|
||||
t.Fatalf("expected ready task count >= 1, got %#v", taskCounts["ready"])
|
||||
}
|
||||
|
||||
data, ok := showResp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data object, got %#v", showResp["data"])
|
||||
}
|
||||
if _, exists := data["tasks"]; exists {
|
||||
t.Fatalf("did not expect tasks array in run show response, got %#v", data["tasks"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchRunShowRejectsMissingRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
stdout, _, exitCode := executeOrchCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "show",
|
||||
"--run", "run_blog_missing",
|
||||
)
|
||||
if exitCode != 40 {
|
||||
t.Fatalf("expected not_found exit code 40, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "not_found")
|
||||
}
|
||||
|
||||
func TestOrchTaskAddRejectsInvalidAcceptanceJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_003",
|
||||
"--goal", "Validate task add input guards",
|
||||
)
|
||||
|
||||
stdout, _, exitCode := executeOrchCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_003",
|
||||
"--task", "T1",
|
||||
"--title", "Implement retry policy",
|
||||
"--acceptance-json", `{"done":true`,
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertErrorMessageContains(t, stdout, "acceptance-json must be valid JSON")
|
||||
}
|
||||
|
||||
func TestOrchTaskAddRejectsInvalidPriority(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_004",
|
||||
"--goal", "Validate task priority input",
|
||||
)
|
||||
|
||||
stdout, _, exitCode := executeOrchCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_004",
|
||||
"--task", "T1",
|
||||
"--title", "Implement retry policy",
|
||||
"--priority", "urgent",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertErrorMessageContains(t, stdout, "priority must be one of low, normal, high")
|
||||
}
|
||||
|
||||
func TestOrchReadyOrdersByPriorityAndRespectsLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_005",
|
||||
"--goal", "Validate ready ordering",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_005",
|
||||
"--task", "T1",
|
||||
"--title", "Low priority task",
|
||||
"--priority", "low",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_005",
|
||||
"--task", "T2",
|
||||
"--title", "Normal priority task",
|
||||
"--priority", "normal",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_005",
|
||||
"--task", "T3",
|
||||
"--title", "High priority task",
|
||||
"--priority", "high",
|
||||
)
|
||||
|
||||
readyOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"ready",
|
||||
"--run", "run_blog_005",
|
||||
"--limit", "2",
|
||||
)
|
||||
|
||||
var readyResp map[string]any
|
||||
mustDecodeJSON(t, readyOut, &readyResp)
|
||||
readyTasks := nestedArray(t, readyResp, "data", "tasks")
|
||||
if len(readyTasks) != 2 {
|
||||
t.Fatalf("expected two ready tasks with limit 2, got %#v", readyTasks)
|
||||
}
|
||||
|
||||
firstTask, ok := readyTasks[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected first ready task object, got %#v", readyTasks[0])
|
||||
}
|
||||
secondTask, ok := readyTasks[1].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected second ready task object, got %#v", readyTasks[1])
|
||||
}
|
||||
if got, _ := firstTask["task_id"].(string); got != "T3" {
|
||||
t.Fatalf("expected first ready task T3, got %#v", firstTask["task_id"])
|
||||
}
|
||||
if got, _ := secondTask["task_id"].(string); got != "T2" {
|
||||
t.Fatalf("expected second ready task T2, got %#v", secondTask["task_id"])
|
||||
}
|
||||
|
||||
for _, item := range readyTasks {
|
||||
task, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected ready task object, got %#v", item)
|
||||
}
|
||||
if got, _ := task["task_id"].(string); got == "T1" {
|
||||
t.Fatalf("did not expect low-priority task T1 within limited ready results")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertErrorMessageContains(t *testing.T, raw string, want string) {
|
||||
t.Helper()
|
||||
|
||||
var payload map[string]any
|
||||
mustDecodeJSON(t, raw, &payload)
|
||||
errorValue, ok := payload["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected error object, got %#v", payload["error"])
|
||||
}
|
||||
message, _ := errorValue["message"].(string)
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", want, message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOrchAnswerAcceptsPayloadJSONWithoutBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedBlockedTaskForAnswerCleanupEdgeTests(t, dbPath, "run_blog_answer_001", "T2", "worker-b")
|
||||
|
||||
answerOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"answer",
|
||||
"--run", "run_blog_answer_001",
|
||||
"--task", "T2",
|
||||
"--payload-json", `{"decision":"stdout","source":"leader"}`,
|
||||
)
|
||||
|
||||
var answerResp map[string]any
|
||||
mustDecodeJSON(t, answerOut, &answerResp)
|
||||
message, ok := nestedValue(t, answerResp, "data", "message").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected answer message object, got %#v", nestedValue(t, answerResp, "data", "message"))
|
||||
}
|
||||
if got, _ := message["kind"].(string); got != "answer" {
|
||||
t.Fatalf("expected answer message kind, got %#v", message["kind"])
|
||||
}
|
||||
payload, ok := message["payload_json"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected payload_json object, got %#v", message["payload_json"])
|
||||
}
|
||||
if got, _ := payload["decision"].(string); got != "stdout" {
|
||||
t.Fatalf("expected payload decision stdout, got %#v", payload["decision"])
|
||||
}
|
||||
if got, _ := payload["source"].(string); got != "leader" {
|
||||
t.Fatalf("expected payload source leader, got %#v", payload["source"])
|
||||
}
|
||||
|
||||
showOut := runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"show",
|
||||
"--thread", threadID,
|
||||
)
|
||||
|
||||
var showResp map[string]any
|
||||
mustDecodeJSON(t, showOut, &showResp)
|
||||
messages := nestedArray(t, showResp, "data", "messages")
|
||||
if len(messages) == 0 {
|
||||
t.Fatalf("expected messages in thread %s", threadID)
|
||||
}
|
||||
lastMessage, ok := messages[len(messages)-1].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected last message object, got %#v", messages[len(messages)-1])
|
||||
}
|
||||
if got, _ := lastMessage["kind"].(string); got != "answer" {
|
||||
t.Fatalf("expected latest message kind answer, got %#v", lastMessage["kind"])
|
||||
}
|
||||
lastPayload, ok := lastMessage["payload_json"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected latest payload_json object, got %#v", lastMessage["payload_json"])
|
||||
}
|
||||
if got, _ := lastPayload["decision"].(string); got != "stdout" {
|
||||
t.Fatalf("expected latest payload decision stdout, got %#v", lastPayload["decision"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchAnswerRejectsEmptyBodyAndPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
_ = seedBlockedTaskForAnswerCleanupEdgeTests(t, dbPath, "run_blog_answer_002", "T2", "worker-b")
|
||||
|
||||
stdout, _, exitCode := executeOrchCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"answer",
|
||||
"--run", "run_blog_answer_002",
|
||||
"--task", "T2",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
}
|
||||
|
||||
func TestOrchCleanupRejectsAttemptWithoutTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_cleanup_002",
|
||||
"--goal", "Validate cleanup selectors",
|
||||
)
|
||||
|
||||
stdout, _, exitCode := executeOrchCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"cleanup",
|
||||
"--run", "run_blog_cleanup_002",
|
||||
"--attempt", "1",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
}
|
||||
|
||||
func TestOrchCleanupReturnsNoMatchingWorkWhenFiltersMiss(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_cleanup_003",
|
||||
"--goal", "Validate cleanup empty result",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_cleanup_003",
|
||||
"--task", "T1",
|
||||
"--title", "Prepare cleanup target",
|
||||
)
|
||||
|
||||
stdout, _, exitCode := executeOrchCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"cleanup",
|
||||
"--run", "run_blog_cleanup_003",
|
||||
"--task", "T1",
|
||||
)
|
||||
if exitCode != 10 {
|
||||
t.Fatalf("expected exit code 10, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "no_matching_work")
|
||||
}
|
||||
|
||||
func seedBlockedTaskForAnswerCleanupEdgeTests(t *testing.T, dbPath, runID, taskID, agent string) string {
|
||||
t.Helper()
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", runID,
|
||||
"--goal", "Prepare blocked task for answer edge tests",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", runID,
|
||||
"--task", taskID,
|
||||
"--title", "Build frontend",
|
||||
"--default-to", agent,
|
||||
)
|
||||
|
||||
dispatchOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", runID,
|
||||
"--task", taskID,
|
||||
)
|
||||
|
||||
var dispatchResp map[string]any
|
||||
mustDecodeJSON(t, dispatchOut, &dispatchResp)
|
||||
threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id")
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"claim",
|
||||
"--agent", agent,
|
||||
"--thread", threadID,
|
||||
)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", agent,
|
||||
"--thread", threadID,
|
||||
"--status", "blocked",
|
||||
"--summary", "Need logging decision",
|
||||
"--payload-json", `{"question":"stdout or stderr?"}`,
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", runID,
|
||||
)
|
||||
|
||||
return threadID
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOrchRunInitCreatesNewRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
initOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_init_001",
|
||||
"--goal", "Build blog MVP",
|
||||
"--summary", "Public blog plus admin CRUD",
|
||||
)
|
||||
|
||||
var initResp map[string]any
|
||||
mustDecodeJSON(t, initOut, &initResp)
|
||||
if got := nestedString(t, initResp, "data", "run", "run_id"); got != "run_blog_init_001" {
|
||||
t.Fatalf("expected run id run_blog_init_001, got %q", got)
|
||||
}
|
||||
if got := nestedString(t, initResp, "data", "run", "goal"); got != "Build blog MVP" {
|
||||
t.Fatalf("expected goal Build blog MVP, got %q", got)
|
||||
}
|
||||
if got := nestedString(t, initResp, "data", "run", "summary"); got != "Public blog plus admin CRUD" {
|
||||
t.Fatalf("expected summary to round-trip, got %q", got)
|
||||
}
|
||||
if got := nestedString(t, initResp, "data", "run", "status"); got != "active" {
|
||||
t.Fatalf("expected new run status active, got %q", got)
|
||||
}
|
||||
assertNonEmptyNestedString(t, initResp, "data", "run", "created_at")
|
||||
assertNonEmptyNestedString(t, initResp, "data", "run", "updated_at")
|
||||
|
||||
showOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "show",
|
||||
"--run", "run_blog_init_001",
|
||||
)
|
||||
|
||||
var showResp map[string]any
|
||||
mustDecodeJSON(t, showOut, &showResp)
|
||||
if got := nestedString(t, showResp, "data", "run", "run_id"); got != "run_blog_init_001" {
|
||||
t.Fatalf("expected persisted run id run_blog_init_001, got %q", got)
|
||||
}
|
||||
if got := nestedString(t, showResp, "data", "run", "status"); got != "active" {
|
||||
t.Fatalf("expected persisted run status active, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchDispatchCreatesAttemptAndThreadForReadyTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_dispatch_001",
|
||||
"--goal", "Build blog MVP",
|
||||
"--summary", "Public blog plus admin CRUD",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_dispatch_001",
|
||||
"--task", "T1",
|
||||
"--title", "Implement retry policy",
|
||||
"--summary", "Add retry policy to HTTP client",
|
||||
"--default-to", "worker-a",
|
||||
)
|
||||
|
||||
dispatchOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_dispatch_001",
|
||||
"--task", "T1",
|
||||
"--body", "Implement retry handling for the HTTP client.",
|
||||
)
|
||||
|
||||
var dispatchResp map[string]any
|
||||
mustDecodeJSON(t, dispatchOut, &dispatchResp)
|
||||
if got := nestedString(t, dispatchResp, "data", "task", "status"); got != "dispatched" {
|
||||
t.Fatalf("expected dispatched task status, got %q", got)
|
||||
}
|
||||
if got := nestedValue(t, dispatchResp, "data", "attempt", "attempt_no").(float64); got != 1 {
|
||||
t.Fatalf("expected attempt_no 1, got %#v", got)
|
||||
}
|
||||
threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id")
|
||||
if threadID == "" {
|
||||
t.Fatal("expected non-empty attempt thread_id")
|
||||
}
|
||||
if got := nestedString(t, dispatchResp, "data", "attempt", "assigned_to"); got != "worker-a" {
|
||||
t.Fatalf("expected assigned_to worker-a, got %q", got)
|
||||
}
|
||||
if got := nestedString(t, dispatchResp, "data", "thread", "thread_id"); got != threadID {
|
||||
t.Fatalf("expected thread.thread_id %q, got %q", threadID, got)
|
||||
}
|
||||
if got := nestedString(t, dispatchResp, "data", "message", "kind"); got != "task" {
|
||||
t.Fatalf("expected first dispatch message kind task, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchBlockedListsLatestQuestionForBlockedTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_blocked_001",
|
||||
"--goal", "Build dependency-aware workflow",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_blocked_001",
|
||||
"--task", "T1",
|
||||
"--title", "Build backend",
|
||||
"--summary", "Implement backend APIs",
|
||||
"--default-to", "worker-a",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_blocked_001",
|
||||
"--task", "T2",
|
||||
"--title", "Build frontend",
|
||||
"--summary", "Implement frontend flows",
|
||||
"--default-to", "worker-b",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dep", "add",
|
||||
"--run", "run_blog_blocked_001",
|
||||
"--task", "T2",
|
||||
"--depends-on", "T1",
|
||||
)
|
||||
|
||||
firstDispatch := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_blocked_001",
|
||||
"--task", "T1",
|
||||
)
|
||||
|
||||
var firstDispatchResp map[string]any
|
||||
mustDecodeJSON(t, firstDispatch, &firstDispatchResp)
|
||||
threadBackend := nestedString(t, firstDispatchResp, "data", "attempt", "thread_id")
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"claim",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadBackend,
|
||||
)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"done",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadBackend,
|
||||
"--summary", "Backend complete",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", "run_blog_blocked_001",
|
||||
)
|
||||
|
||||
secondDispatch := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_blocked_001",
|
||||
"--task", "T2",
|
||||
)
|
||||
|
||||
var secondDispatchResp map[string]any
|
||||
mustDecodeJSON(t, secondDispatch, &secondDispatchResp)
|
||||
threadFrontend := nestedString(t, secondDispatchResp, "data", "attempt", "thread_id")
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"claim",
|
||||
"--agent", "worker-b",
|
||||
"--thread", threadFrontend,
|
||||
)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", "worker-b",
|
||||
"--thread", threadFrontend,
|
||||
"--status", "blocked",
|
||||
"--summary", "Need logging decision",
|
||||
"--payload-json", `{"question":"stdout or stderr?"}`,
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", "run_blog_blocked_001",
|
||||
)
|
||||
|
||||
blockedOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"blocked",
|
||||
"--run", "run_blog_blocked_001",
|
||||
)
|
||||
|
||||
var blockedResp map[string]any
|
||||
mustDecodeJSON(t, blockedOut, &blockedResp)
|
||||
blockedTasks := nestedArray(t, blockedResp, "data", "blocked")
|
||||
if len(blockedTasks) != 1 {
|
||||
t.Fatalf("expected one blocked task, got %#v", blockedTasks)
|
||||
}
|
||||
blockedTask, ok := blockedTasks[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected blocked task object, got %#v", blockedTasks[0])
|
||||
}
|
||||
if got := nestedString(t, blockedTask, "task", "task_id"); got != "T2" {
|
||||
t.Fatalf("expected blocked task T2, got %q", got)
|
||||
}
|
||||
if got := nestedString(t, blockedTask, "question", "kind"); got != "question" {
|
||||
t.Fatalf("expected question.kind=question, got %q", got)
|
||||
}
|
||||
if got := nestedString(t, blockedTask, "question", "summary"); got != "Need logging decision" {
|
||||
t.Fatalf("expected question summary to match latest blocked message, got %q", got)
|
||||
}
|
||||
questionPayload, ok := nestedValue(t, blockedTask, "question", "payload_json").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected question payload_json object, got %#v", nestedValue(t, blockedTask, "question", "payload_json"))
|
||||
}
|
||||
if got, _ := questionPayload["question"].(string); got != "stdout or stderr?" {
|
||||
t.Fatalf("expected latest question payload, got %#v", questionPayload["question"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchStatusReturnsRunSummaryAndTaskList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_status_001",
|
||||
"--goal", "Build blog MVP",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_status_001",
|
||||
"--task", "T1",
|
||||
"--title", "Implement retry policy",
|
||||
"--default-to", "worker-a",
|
||||
)
|
||||
|
||||
dispatchOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_status_001",
|
||||
"--task", "T1",
|
||||
"--body", "Implement retry handling for the HTTP client.",
|
||||
)
|
||||
|
||||
var dispatchResp map[string]any
|
||||
mustDecodeJSON(t, dispatchOut, &dispatchResp)
|
||||
threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id")
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"claim",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"done",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--summary", "Retry policy implemented",
|
||||
"--body", "The HTTP client now retries transient failures.",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", "run_blog_status_001",
|
||||
)
|
||||
|
||||
statusOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"status",
|
||||
"--run", "run_blog_status_001",
|
||||
)
|
||||
|
||||
var statusResp map[string]any
|
||||
mustDecodeJSON(t, statusOut, &statusResp)
|
||||
if got := nestedString(t, statusResp, "data", "run", "run_id"); got != "run_blog_status_001" {
|
||||
t.Fatalf("expected run_id run_blog_status_001, got %q", got)
|
||||
}
|
||||
if got := nestedString(t, statusResp, "data", "run", "status"); got != "done" {
|
||||
t.Fatalf("expected run status done, got %q", got)
|
||||
}
|
||||
taskCounts, ok := nestedValue(t, statusResp, "data", "task_counts").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected task_counts object, got %#v", nestedValue(t, statusResp, "data", "task_counts"))
|
||||
}
|
||||
if got, _ := taskCounts["done"].(float64); got != 1 {
|
||||
t.Fatalf("expected done task count 1, got %#v", taskCounts["done"])
|
||||
}
|
||||
tasks := nestedArray(t, statusResp, "data", "tasks")
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("expected one task in status response, got %#v", tasks)
|
||||
}
|
||||
task, ok := tasks[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected task object, got %#v", tasks[0])
|
||||
}
|
||||
if got, _ := task["task_id"].(string); got != "T1" {
|
||||
t.Fatalf("expected task_id T1, got %#v", task["task_id"])
|
||||
}
|
||||
if got, _ := task["status"].(string); got != "done" {
|
||||
t.Fatalf("expected task status done, got %#v", task["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchReconcileMapsFailedThreadToTerminalTaskState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_reconcile_001",
|
||||
"--goal", "Build blog MVP",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_reconcile_001",
|
||||
"--task", "T1",
|
||||
"--title", "Implement retry policy",
|
||||
"--default-to", "worker-a",
|
||||
)
|
||||
|
||||
dispatchOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_reconcile_001",
|
||||
"--task", "T1",
|
||||
"--body", "Implement retry handling for the HTTP client.",
|
||||
)
|
||||
|
||||
var dispatchResp map[string]any
|
||||
mustDecodeJSON(t, dispatchOut, &dispatchResp)
|
||||
threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id")
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"claim",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"fail",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--summary", "Retry policy failed",
|
||||
"--body", "The HTTP client kept failing integration tests.",
|
||||
)
|
||||
|
||||
reconcileOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", "run_blog_reconcile_001",
|
||||
)
|
||||
|
||||
var reconcileResp map[string]any
|
||||
mustDecodeJSON(t, reconcileOut, &reconcileResp)
|
||||
updatedTasks := nestedArray(t, reconcileResp, "data", "updated_tasks")
|
||||
if len(updatedTasks) != 1 {
|
||||
t.Fatalf("expected one updated task after failed reconcile, got %#v", updatedTasks)
|
||||
}
|
||||
task, ok := updatedTasks[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected updated task object, got %#v", updatedTasks[0])
|
||||
}
|
||||
if got, _ := task["task_id"].(string); got != "T1" {
|
||||
t.Fatalf("expected updated task T1, got %#v", task["task_id"])
|
||||
}
|
||||
if got, _ := task["status"].(string); got != "failed" {
|
||||
t.Fatalf("expected reconciled task status failed, got %#v", task["status"])
|
||||
}
|
||||
taskCounts, ok := nestedValue(t, reconcileResp, "data", "task_counts").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected task_counts object, got %#v", nestedValue(t, reconcileResp, "data", "task_counts"))
|
||||
}
|
||||
if got, _ := taskCounts["failed"].(float64); got != 1 {
|
||||
t.Fatalf("expected failed task count 1 after reconcile, got %#v", taskCounts["failed"])
|
||||
}
|
||||
|
||||
statusOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"status",
|
||||
"--run", "run_blog_reconcile_001",
|
||||
)
|
||||
|
||||
var statusResp map[string]any
|
||||
mustDecodeJSON(t, statusOut, &statusResp)
|
||||
if got := nestedString(t, statusResp, "data", "run", "status"); got != "failed" {
|
||||
t.Fatalf("expected run status failed after failed reconcile, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchWorkflowStrictWorktreeDispatchToCleanup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
repoPath := initGitRepo(t)
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_workflow_worktree_001",
|
||||
"--goal", "Validate strict worktree dispatch",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_workflow_worktree_001",
|
||||
"--task", "T1",
|
||||
"--title", "Implement backend",
|
||||
"--default-to", "worker-a",
|
||||
)
|
||||
|
||||
dispatchOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_workflow_worktree_001",
|
||||
"--task", "T1",
|
||||
"--repo-path", repoPath,
|
||||
"--workspace-root", ".orch/worktrees",
|
||||
"--strict-worktree",
|
||||
"--body", "Implement inside isolated worktree.",
|
||||
)
|
||||
|
||||
var dispatchResp map[string]any
|
||||
mustDecodeJSON(t, dispatchOut, &dispatchResp)
|
||||
threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id")
|
||||
worktreePath := nestedString(t, dispatchResp, "data", "attempt", "worktree_path")
|
||||
if worktreePath == "" {
|
||||
t.Fatal("expected non-empty worktree_path for strict worktree workflow")
|
||||
}
|
||||
if got := nestedString(t, dispatchResp, "data", "attempt", "workspace_status"); got != "created" {
|
||||
t.Fatalf("expected workspace_status created, got %q", got)
|
||||
}
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"claim",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"done",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--summary", "Backend complete",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", "run_blog_workflow_worktree_001",
|
||||
)
|
||||
|
||||
cleanupOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"cleanup",
|
||||
"--run", "run_blog_workflow_worktree_001",
|
||||
"--task", "T1",
|
||||
"--attempt", "1",
|
||||
)
|
||||
|
||||
var cleanupResp map[string]any
|
||||
mustDecodeJSON(t, cleanupOut, &cleanupResp)
|
||||
cleaned := nestedArray(t, cleanupResp, "data", "cleaned")
|
||||
if len(cleaned) != 1 {
|
||||
t.Fatalf("expected one cleaned attempt, got %#v", cleaned)
|
||||
}
|
||||
cleanedAttempt, ok := cleaned[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected cleaned attempt object, got %#v", cleaned[0])
|
||||
}
|
||||
if got, _ := cleanedAttempt["workspace_status"].(string); got != "cleaned" {
|
||||
t.Fatalf("expected cleaned workspace_status, got %#v", cleanedAttempt["workspace_status"])
|
||||
}
|
||||
if _, err := os.Stat(worktreePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected cleaned worktree path to be removed, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchWorkflowCouncilReviewEndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
runID := "council_blog_workflow_001"
|
||||
|
||||
startOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"council", "start",
|
||||
"--run", runID,
|
||||
"--target", "Review the current blog architecture.",
|
||||
)
|
||||
|
||||
var startResp map[string]any
|
||||
mustDecodeJSON(t, startOut, &startResp)
|
||||
reviewers := nestedArray(t, startResp, "data", "reviewers")
|
||||
if len(reviewers) != 3 {
|
||||
t.Fatalf("expected three reviewers from council start, got %#v", reviewers)
|
||||
}
|
||||
|
||||
completeCouncilWorkflowReviewersForRemainingTests(t, dbPath, runID)
|
||||
|
||||
waitOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"council", "wait",
|
||||
"--run", runID,
|
||||
"--timeout-seconds", "2",
|
||||
)
|
||||
|
||||
var waitResp map[string]any
|
||||
mustDecodeJSON(t, waitOut, &waitResp)
|
||||
if woke, _ := nestedValue(t, waitResp, "data", "woke").(bool); !woke {
|
||||
t.Fatalf("expected council wait to wake, got %#v", waitResp)
|
||||
}
|
||||
if allComplete, _ := nestedValue(t, waitResp, "data", "all_complete").(bool); !allComplete {
|
||||
t.Fatalf("expected all reviewers complete, got %#v", waitResp)
|
||||
}
|
||||
|
||||
tallyOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"council", "tally",
|
||||
"--run", runID,
|
||||
"--similarity", "normal",
|
||||
)
|
||||
|
||||
var tallyResp map[string]any
|
||||
mustDecodeJSON(t, tallyOut, &tallyResp)
|
||||
if got := nestedString(t, tallyResp, "data", "similarity"); got != "normal" {
|
||||
t.Fatalf("expected normal tally similarity, got %q", got)
|
||||
}
|
||||
tallyCounts, ok := nestedValue(t, tallyResp, "data", "counts").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected tally counts object, got %#v", nestedValue(t, tallyResp, "data", "counts"))
|
||||
}
|
||||
if got, _ := tallyCounts["consensus"].(float64); got != 1 {
|
||||
t.Fatalf("expected one consensus group, got %#v", tallyCounts["consensus"])
|
||||
}
|
||||
if got, _ := tallyCounts["majority"].(float64); got != 1 {
|
||||
t.Fatalf("expected one majority group, got %#v", tallyCounts["majority"])
|
||||
}
|
||||
if got, _ := tallyCounts["minority"].(float64); got != 1 {
|
||||
t.Fatalf("expected one minority group, got %#v", tallyCounts["minority"])
|
||||
}
|
||||
|
||||
reportOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"council", "report",
|
||||
"--run", runID,
|
||||
)
|
||||
|
||||
var reportResp map[string]any
|
||||
mustDecodeJSON(t, reportOut, &reportResp)
|
||||
show := nestedArray(t, reportResp, "data", "show")
|
||||
if len(show) != 2 || show[0] != "consensus" || show[1] != "majority" {
|
||||
t.Fatalf("expected default report show [consensus majority], got %#v", show)
|
||||
}
|
||||
grouped := nestedArray(t, reportResp, "data", "grouped_recommendations")
|
||||
if len(grouped) != 2 {
|
||||
t.Fatalf("expected default report to include consensus and majority groups, got %#v", grouped)
|
||||
}
|
||||
artifacts := nestedArray(t, reportResp, "data", "report_artifacts")
|
||||
if len(artifacts) != 1 {
|
||||
t.Fatalf("expected one report artifact, got %#v", artifacts)
|
||||
}
|
||||
artifact, ok := artifacts[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected report artifact object, got %#v", artifacts[0])
|
||||
}
|
||||
reportPath, _ := artifact["path"].(string)
|
||||
if reportPath == "" {
|
||||
t.Fatalf("expected report artifact path, got %#v", artifact["path"])
|
||||
}
|
||||
if _, err := os.Stat(reportPath); err != nil {
|
||||
t.Fatalf("expected report artifact to exist at %q: %v", reportPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNonEmptyNestedString(t *testing.T, value map[string]any, keys ...string) {
|
||||
t.Helper()
|
||||
|
||||
if got := nestedString(t, value, keys...); got == "" {
|
||||
t.Fatalf("expected non-empty string at %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func completeCouncilWorkflowReviewersForRemainingTests(t *testing.T, dbPath, runID string) {
|
||||
t.Helper()
|
||||
|
||||
completeCouncilReviewer(
|
||||
t,
|
||||
dbPath,
|
||||
runID,
|
||||
"architecture-reviewer",
|
||||
`{"reviewer_role":"architecture-reviewer","findings":[{"title":"Split contracts","summary":"Transport contracts are mixed into UI code.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This lowers coupling.","confidence":"high","tags":["architecture"],"target_refs":{"repo_path":"."}},{"title":"Share helpers","summary":"Council report rendering paths are repeated.","proposal":"Introduce shared council coordinator helpers for report rendering.","rationale":"This keeps report assembly consistent.","confidence":"medium","tags":["reporting"],"target_refs":{"repo_path":"."}}]}`,
|
||||
)
|
||||
completeCouncilReviewer(
|
||||
t,
|
||||
dbPath,
|
||||
runID,
|
||||
"implementation-reviewer",
|
||||
`{"reviewer_role":"implementation-reviewer","findings":[{"title":"Extract contracts","summary":"Shared transport shapes are duplicated.","proposal":"Move API contract definitions into dedicated module","rationale":"This reduces duplication.","confidence":"high","tags":["maintainability"],"target_refs":{"repo_path":"."}},{"title":"Reuse report helpers","summary":"Formatting logic should stay shared.","proposal":"Introduce shared council coordinator helpers for report rendering","rationale":"This avoids formatter drift.","confidence":"medium","tags":["reporting"],"target_refs":{"repo_path":"."}}]}`,
|
||||
)
|
||||
completeCouncilReviewer(
|
||||
t,
|
||||
dbPath,
|
||||
runID,
|
||||
"risk-reviewer",
|
||||
`{"reviewer_role":"risk-reviewer","findings":[{"title":"Lock contracts","summary":"Contract drift becomes risky over time.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This reduces integration regressions.","confidence":"high","tags":["risk"],"target_refs":{"repo_path":"."}},{"title":"Cover JSON output","summary":"The council report response should stay stable.","proposal":"Add regression tests for council report JSON output.","rationale":"This catches contract regressions earlier.","confidence":"high","tags":["testing"],"target_refs":{"repo_path":"."}}]}`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package orch
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newCouncilCmd(root *rootOptions) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "council",
|
||||
Short: "Council review workflow commands",
|
||||
}
|
||||
|
||||
cmd.AddCommand(newCouncilStartCmd(root))
|
||||
cmd.AddCommand(newCouncilWaitCmd(root))
|
||||
cmd.AddCommand(newCouncilTallyCmd(root))
|
||||
cmd.AddCommand(newCouncilReportCmd(root))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type councilReportOptions struct {
|
||||
runID string
|
||||
show string
|
||||
}
|
||||
|
||||
func newCouncilReportCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &councilReportOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "report",
|
||||
Short: "Render the final grouped council output",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
orchStore := store.NewOrchStore(sqlDB)
|
||||
result, err := orchStore.BuildCouncilReport(ctx, store.CouncilReportInput{
|
||||
RunID: opts.runID,
|
||||
Show: opts.show,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reportPath := councilReportArtifactPath(root.dbPath, result.RunID)
|
||||
if err := os.MkdirAll(filepath.Dir(reportPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create council report directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(reportPath, []byte(result.Markdown), 0o644); err != nil {
|
||||
return fmt.Errorf("write council report artifact: %w", err)
|
||||
}
|
||||
|
||||
result.ReportArtifacts = []store.CouncilReportArtifact{
|
||||
{
|
||||
Kind: "markdown",
|
||||
Path: reportPath,
|
||||
},
|
||||
}
|
||||
if err := orchStore.PersistCouncilReport(ctx, store.CouncilPersistReportInput{
|
||||
RunID: result.RunID,
|
||||
Show: result.Show,
|
||||
Summary: result.Summary,
|
||||
MarkdownPath: reportPath,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "council report",
|
||||
Data: map[string]any{
|
||||
"run_id": result.RunID,
|
||||
"show": result.Show,
|
||||
"summary": result.Summary,
|
||||
"grouped_recommendations": result.GroupedRecommendations,
|
||||
"report_artifacts": result.ReportArtifacts,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprint(cmd.OutOrStdout(), result.Markdown)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Council run ID")
|
||||
cmd.Flags().StringVar(&opts.show, "show", "", "Buckets to show: consensus,majority,minority,all")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func councilReportArtifactPath(dbPath, runID string) string {
|
||||
baseDir := "."
|
||||
dbDir := filepath.Dir(dbPath)
|
||||
switch {
|
||||
case dbDir == "", dbDir == ".":
|
||||
baseDir = "."
|
||||
case filepath.Base(dbDir) == ".agents":
|
||||
baseDir = filepath.Dir(dbDir)
|
||||
default:
|
||||
baseDir = dbDir
|
||||
}
|
||||
if baseDir == "" {
|
||||
baseDir = "."
|
||||
}
|
||||
|
||||
fileName := strings.ReplaceAll(strings.TrimSpace(runID), string(os.PathSeparator), "_") + ".md"
|
||||
return filepath.Join(baseDir, ".orch", "reports", fileName)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOrchCouncilReportRejectsBeforeTally(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
runID := "council_blog_report_010"
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"council", "start",
|
||||
"--run", runID,
|
||||
"--target", "Review the council reporting flow.",
|
||||
)
|
||||
|
||||
stdout, _, exitCode := executeOrchCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"council", "report",
|
||||
"--run", runID,
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_state")
|
||||
|
||||
if msg := orchErrorMessage(t, stdout); !strings.Contains(msg, "run council tally first") {
|
||||
t.Fatalf("expected error message to require council tally first, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchCouncilReportRejectsInvalidShow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
runID := "council_blog_report_012"
|
||||
seedCouncilReportRun(t, dbPath, runID)
|
||||
|
||||
stdout, _, exitCode := executeOrchCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"council", "report",
|
||||
"--run", runID,
|
||||
"--show", "consensus,invalid",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
|
||||
msg := orchErrorMessage(t, stdout)
|
||||
for _, expected := range []string{"consensus", "majority", "minority", "all"} {
|
||||
if !strings.Contains(msg, expected) {
|
||||
t.Fatalf("expected invalid --show message to mention %q, got %q", expected, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchCouncilReportDefaultsToConsensusForOnlyUnanimousRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
runID := "council_blog_report_011"
|
||||
seedOnlyUnanimousCouncilReportRun(t, dbPath, runID)
|
||||
|
||||
reportOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"council", "report",
|
||||
"--run", runID,
|
||||
)
|
||||
|
||||
var reportResp map[string]any
|
||||
mustDecodeJSON(t, reportOut, &reportResp)
|
||||
if ok, _ := reportResp["ok"].(bool); !ok {
|
||||
t.Fatalf("expected ok=true, got %#v", reportResp)
|
||||
}
|
||||
if got := nestedString(t, reportResp, "data", "run_id"); got != runID {
|
||||
t.Fatalf("expected run id %q, got %q", runID, got)
|
||||
}
|
||||
|
||||
show := nestedArray(t, reportResp, "data", "show")
|
||||
if len(show) != 1 || show[0] != "consensus" {
|
||||
t.Fatalf("expected unanimous-only default show bucket [consensus], got %#v", show)
|
||||
}
|
||||
|
||||
summary, ok := nestedValue(t, reportResp, "data", "summary").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected summary object, got %#v", nestedValue(t, reportResp, "data", "summary"))
|
||||
}
|
||||
if got, _ := summary["consensus"].(float64); got != 1 {
|
||||
t.Fatalf("expected one consensus group, got %#v", summary["consensus"])
|
||||
}
|
||||
if got, _ := summary["majority"].(float64); got != 1 {
|
||||
t.Fatalf("expected one majority group, got %#v", summary["majority"])
|
||||
}
|
||||
if got, _ := summary["minority"].(float64); got != 1 {
|
||||
t.Fatalf("expected one minority group, got %#v", summary["minority"])
|
||||
}
|
||||
|
||||
groups := nestedArray(t, reportResp, "data", "grouped_recommendations")
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("expected one reported recommendation group, got %#v", groups)
|
||||
}
|
||||
group, ok := groups[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected grouped recommendation object, got %#v", groups[0])
|
||||
}
|
||||
if got, _ := group["bucket"].(string); got != "consensus" {
|
||||
t.Fatalf("expected only reported bucket to be consensus, got %#v", group["bucket"])
|
||||
}
|
||||
|
||||
artifacts := nestedArray(t, reportResp, "data", "report_artifacts")
|
||||
if len(artifacts) != 1 {
|
||||
t.Fatalf("expected one report artifact, got %#v", artifacts)
|
||||
}
|
||||
artifact, ok := artifacts[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected report artifact object, got %#v", artifacts[0])
|
||||
}
|
||||
reportPath, _ := artifact["path"].(string)
|
||||
if reportPath == "" {
|
||||
t.Fatalf("expected markdown artifact path, got %#v", artifact["path"])
|
||||
}
|
||||
if _, err := os.Stat(reportPath); err != nil {
|
||||
t.Fatalf("expected markdown artifact to exist at %q: %v", reportPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func orchErrorMessage(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
|
||||
var payload map[string]any
|
||||
mustDecodeJSON(t, raw, &payload)
|
||||
errorValue, ok := payload["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected error object, got %#v", payload["error"])
|
||||
}
|
||||
msg, ok := errorValue["message"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected error message string, got %#v", errorValue["message"])
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func seedOnlyUnanimousCouncilReportRun(t *testing.T, dbPath, runID string) {
|
||||
t.Helper()
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"council", "start",
|
||||
"--run", runID,
|
||||
"--target", "Review the council reporting flow.",
|
||||
"--only-unanimous",
|
||||
)
|
||||
|
||||
completeCouncilReviewer(
|
||||
t,
|
||||
dbPath,
|
||||
runID,
|
||||
"architecture-reviewer",
|
||||
`{"reviewer_role":"architecture-reviewer","findings":[{"title":"Split contracts","summary":"Transport contracts are mixed into UI code.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This lowers coupling.","confidence":"high","tags":["architecture"],"target_refs":{"repo_path":"."}},{"title":"Share helpers","summary":"Council report rendering paths are repeated.","proposal":"Introduce shared council coordinator helpers for report rendering.","rationale":"This keeps report assembly consistent.","confidence":"medium","tags":["reporting"],"target_refs":{"repo_path":"."}}]}`,
|
||||
)
|
||||
completeCouncilReviewer(
|
||||
t,
|
||||
dbPath,
|
||||
runID,
|
||||
"implementation-reviewer",
|
||||
`{"reviewer_role":"implementation-reviewer","findings":[{"title":"Extract contracts","summary":"Shared transport shapes are duplicated.","proposal":"Move API contract definitions into dedicated module","rationale":"This reduces duplication.","confidence":"high","tags":["maintainability"],"target_refs":{"repo_path":"."}},{"title":"Reuse report helpers","summary":"Formatting logic should stay shared.","proposal":"Introduce shared council coordinator helpers for report rendering","rationale":"This avoids formatter drift.","confidence":"medium","tags":["reporting"],"target_refs":{"repo_path":"."}}]}`,
|
||||
)
|
||||
completeCouncilReviewer(
|
||||
t,
|
||||
dbPath,
|
||||
runID,
|
||||
"risk-reviewer",
|
||||
`{"reviewer_role":"risk-reviewer","findings":[{"title":"Lock contracts","summary":"Contract drift becomes risky over time.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This reduces integration regressions.","confidence":"high","tags":["risk"],"target_refs":{"repo_path":"."}},{"title":"Cover JSON output","summary":"The council report response should stay stable.","proposal":"Add regression tests for council report JSON output.","rationale":"This catches contract regressions earlier.","confidence":"high","tags":["testing"],"target_refs":{"repo_path":"."}}]}`,
|
||||
)
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"council", "tally",
|
||||
"--run", runID,
|
||||
"--similarity", "normal",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type councilStartOptions struct {
|
||||
runID string
|
||||
target string
|
||||
targetFile string
|
||||
repoPath string
|
||||
targetTaskID string
|
||||
targetType string
|
||||
mode string
|
||||
outputMode string
|
||||
onlyUnanimous bool
|
||||
}
|
||||
|
||||
func newCouncilStartCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &councilStartOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Create and dispatch a three-reviewer council run",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
result, err := store.NewOrchStore(sqlDB).StartCouncil(ctx, store.CouncilStartInput{
|
||||
RunID: opts.runID,
|
||||
Target: opts.target,
|
||||
TargetFile: opts.targetFile,
|
||||
RepoPath: opts.repoPath,
|
||||
TargetTaskID: opts.targetTaskID,
|
||||
TargetType: opts.targetType,
|
||||
Mode: opts.mode,
|
||||
OutputMode: opts.outputMode,
|
||||
OnlyUnanimous: opts.onlyUnanimous,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "council start",
|
||||
Data: map[string]any{
|
||||
"run_id": result.Run.RunID,
|
||||
"mode": result.Run.Mode,
|
||||
"target_type": result.Run.TargetType,
|
||||
"output": result.Run.OutputMode,
|
||||
"only_unanimous": result.Run.OnlyUnanimous,
|
||||
"target": result.Input,
|
||||
"reviewers": result.Reviewers,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "started council run %s with %d reviewers\n", result.Run.RunID, len(result.Reviewers))
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().StringVar(&opts.target, "target", "", "Inline target prompt")
|
||||
cmd.Flags().StringVar(&opts.targetFile, "target-file", "", "Optional target context file")
|
||||
cmd.Flags().StringVar(&opts.repoPath, "repo-path", "", "Optional repository path for review context")
|
||||
cmd.Flags().StringVar(&opts.targetTaskID, "task-id", "", "Optional related task ID")
|
||||
cmd.Flags().StringVar(&opts.targetType, "target-type", "mixed", "Target type: text, repo, or mixed")
|
||||
cmd.Flags().StringVar(&opts.mode, "mode", "brainstorm", "Council mode: brainstorm or review")
|
||||
cmd.Flags().StringVar(&opts.outputMode, "output", "both", "Output mode: markdown, json, or both")
|
||||
cmd.Flags().BoolVar(&opts.onlyUnanimous, "only-unanimous", false, "Show only unanimous recommendations in downstream report defaults")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type councilTallyOptions struct {
|
||||
runID string
|
||||
similarity string
|
||||
}
|
||||
|
||||
func newCouncilTallyCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &councilTallyOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "tally",
|
||||
Short: "Group reviewer findings and compute council support counts",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
result, err := store.NewOrchStore(sqlDB).TallyCouncil(ctx, store.CouncilTallyInput{
|
||||
RunID: opts.runID,
|
||||
Similarity: opts.similarity,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "council tally",
|
||||
Data: map[string]any{
|
||||
"run_id": result.RunID,
|
||||
"similarity": result.Similarity,
|
||||
"counts": result.Counts,
|
||||
"grouped_recommendations": result.GroupedRecommendations,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "tallied council run %s into %d groups\n", result.RunID, len(result.GroupedRecommendations))
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Council run ID")
|
||||
cmd.Flags().StringVar(&opts.similarity, "similarity", "normal", "Grouping mode: strict or normal")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type councilWaitOptions struct {
|
||||
runID string
|
||||
timeoutSeconds int
|
||||
}
|
||||
|
||||
func newCouncilWaitCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &councilWaitOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "wait",
|
||||
Short: "Block until all council reviewers complete or timeout is reached",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
result, err := store.NewOrchStore(sqlDB).WaitForCouncil(ctx, store.CouncilWaitInput{
|
||||
RunID: opts.runID,
|
||||
Timeout: time.Duration(opts.timeoutSeconds) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "council wait",
|
||||
Data: map[string]any{
|
||||
"run_id": result.RunID,
|
||||
"woke": result.Woke,
|
||||
"all_complete": result.AllComplete,
|
||||
"reviewers": result.ReviewerStatuses,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
if !result.Woke {
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "council wait timed out for run %s\n", result.RunID)
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "all council reviewers completed for run %s\n", result.RunID)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Council run ID")
|
||||
cmd.Flags().IntVar(&opts.timeoutSeconds, "timeout-seconds", 0, "Maximum time to wait before timing out")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/db"
|
||||
)
|
||||
|
||||
func openOrchDB(ctx context.Context, dbPath string) (*sql.DB, error) {
|
||||
sqlDB, err := db.Open(ctx, dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.ApplyMigrations(ctx, sqlDB); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sqlDB, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type depAddOptions struct {
|
||||
runID string
|
||||
taskID string
|
||||
dependsOn string
|
||||
}
|
||||
|
||||
func newDepCmd(root *rootOptions) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "dep",
|
||||
Short: "Task dependency commands",
|
||||
}
|
||||
|
||||
cmd.AddCommand(newDepAddCmd(root))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDepAddCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &depAddOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a dependency edge to a task",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
dep, err := store.NewOrchStore(sqlDB).AddDependency(ctx, store.AddDependencyInput{
|
||||
RunID: opts.runID,
|
||||
TaskID: opts.taskID,
|
||||
DependsOnTaskID: opts.dependsOn,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "dep add",
|
||||
Data: map[string]any{
|
||||
"dependency": dep,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "added dependency %s -> %s\n", dep.TaskID, dep.DependsOnTaskID)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID")
|
||||
cmd.Flags().StringVar(&opts.dependsOn, "depends-on", "", "Dependency task ID")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
_ = cmd.MarkFlagRequired("task")
|
||||
_ = cmd.MarkFlagRequired("depends-on")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type dispatchOptions struct {
|
||||
runID string
|
||||
taskID string
|
||||
toAgent string
|
||||
body string
|
||||
bodyFile string
|
||||
baseRef string
|
||||
repoPath string
|
||||
workspaceRoot string
|
||||
strictWorktree bool
|
||||
}
|
||||
|
||||
func newDispatchCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &dispatchOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "dispatch",
|
||||
Short: "Dispatch a ready task to a worker through inbox",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
body, err := resolveBodyValue(opts.body, opts.bodyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
result, err := store.NewOrchStore(sqlDB).DispatchTask(ctx, store.DispatchInput{
|
||||
RunID: opts.runID,
|
||||
TaskID: opts.taskID,
|
||||
ToAgent: opts.toAgent,
|
||||
Body: body,
|
||||
BaseRef: opts.baseRef,
|
||||
PrepareWorkspace: newDispatchWorkspacePreparer(cmd, *opts),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "dispatch",
|
||||
Data: map[string]any{
|
||||
"task": result.Task,
|
||||
"attempt": result.Attempt,
|
||||
"thread": result.Thread,
|
||||
"message": result.Message,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(
|
||||
cmd.OutOrStdout(),
|
||||
"dispatched task %s to %s as thread %s\n",
|
||||
result.Task.TaskID,
|
||||
result.Attempt.AssignedTo,
|
||||
result.Attempt.ThreadID,
|
||||
)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID")
|
||||
cmd.Flags().StringVar(&opts.toAgent, "to", "", "Worker agent override")
|
||||
cmd.Flags().StringVar(&opts.body, "body", "", "Task message body")
|
||||
cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read task message body from file")
|
||||
cmd.Flags().StringVar(&opts.baseRef, "base-ref", "", "Optional base ref to record on the attempt")
|
||||
cmd.Flags().StringVar(&opts.repoPath, "repo-path", "", "Source repository path for worktree dispatch")
|
||||
cmd.Flags().StringVar(&opts.workspaceRoot, "workspace-root", "", "Workspace root for worktree dispatch")
|
||||
cmd.Flags().BoolVar(&opts.strictWorktree, "strict-worktree", false, "Require strict worktree setup")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
_ = cmd.MarkFlagRequired("task")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
)
|
||||
|
||||
func Execute(args []string, stdout, stderr io.Writer) int {
|
||||
cmd := NewRootCmd()
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
cmd.SetArgs(args)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
jsonOutput := hasJSONFlag(args)
|
||||
renderError(stdout, stderr, jsonOutput, err)
|
||||
return exitCodeForError(err)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func exitCodeForError(err error) int {
|
||||
var cliErr *protocol.CLIError
|
||||
if errors.As(err, &cliErr) {
|
||||
return cliErr.ExitCode
|
||||
}
|
||||
|
||||
switch {
|
||||
case isUsageError(err):
|
||||
return 30
|
||||
case errors.Is(err, store.ErrLeaseConflict):
|
||||
return 20
|
||||
case errors.Is(err, store.ErrRunNotFound), errors.Is(err, store.ErrTaskNotFound), errors.Is(err, store.ErrThreadNotFound), errors.Is(err, store.ErrMessageNotFound):
|
||||
return 40
|
||||
case errors.Is(err, store.ErrInvalidInput), errors.Is(err, store.ErrInvalidState), errors.Is(err, store.ErrNoActiveLease):
|
||||
return 30
|
||||
default:
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
func errorCodeForError(err error) string {
|
||||
var cliErr *protocol.CLIError
|
||||
if errors.As(err, &cliErr) {
|
||||
return cliErr.Code
|
||||
}
|
||||
|
||||
switch {
|
||||
case isUsageError(err):
|
||||
return "invalid_input"
|
||||
case errors.Is(err, store.ErrLeaseConflict):
|
||||
return "conflict"
|
||||
case errors.Is(err, store.ErrRunNotFound), errors.Is(err, store.ErrTaskNotFound), errors.Is(err, store.ErrThreadNotFound), errors.Is(err, store.ErrMessageNotFound):
|
||||
return "not_found"
|
||||
case errors.Is(err, store.ErrInvalidInput):
|
||||
return "invalid_input"
|
||||
case errors.Is(err, store.ErrInvalidState), errors.Is(err, store.ErrNoActiveLease):
|
||||
return "invalid_state"
|
||||
default:
|
||||
return "internal_error"
|
||||
}
|
||||
}
|
||||
|
||||
func renderError(stdout, stderr io.Writer, jsonOutput bool, err error) {
|
||||
message := errorMessage(err)
|
||||
if jsonOutput {
|
||||
_ = protocol.WriteJSON(stdout, protocol.Error{
|
||||
OK: false,
|
||||
Error: protocol.ErrorPayload{
|
||||
Code: errorCodeForError(err),
|
||||
Message: message,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(stderr, message)
|
||||
}
|
||||
|
||||
func errorMessage(err error) string {
|
||||
var cliErr *protocol.CLIError
|
||||
if errors.As(err, &cliErr) {
|
||||
return cliErr.Message
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func hasJSONFlag(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--json" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(arg, "--json=") {
|
||||
return !strings.HasSuffix(arg, "=false")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isUsageError(err error) bool {
|
||||
message := err.Error()
|
||||
return strings.HasPrefix(message, "required flag(s)") ||
|
||||
strings.HasPrefix(message, "unknown flag:") ||
|
||||
strings.HasPrefix(message, "unknown command ") ||
|
||||
strings.Contains(message, " accepts ") ||
|
||||
strings.Contains(message, "invalid argument ")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func initGitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
repoPath := filepath.Join(t.TempDir(), "repo")
|
||||
if err := os.MkdirAll(repoPath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir repo path: %v", err)
|
||||
}
|
||||
|
||||
runGitCommand(t, repoPath, "init")
|
||||
runGitCommand(t, repoPath, "config", "user.email", "test@example.com")
|
||||
runGitCommand(t, repoPath, "config", "user.name", "Test User")
|
||||
|
||||
readmePath := filepath.Join(repoPath, "README.md")
|
||||
if err := os.WriteFile(readmePath, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("write README.md: %v", err)
|
||||
}
|
||||
|
||||
runGitCommand(t, repoPath, "add", "README.md")
|
||||
runGitCommand(t, repoPath, "commit", "-m", "init")
|
||||
|
||||
return repoPath
|
||||
}
|
||||
|
||||
func gitHeadCommit(t *testing.T, repoPath string) string {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("git", "-C", repoPath, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git rev-parse HEAD in %s: %v\n%s", repoPath, err, output)
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func runGitCommand(t *testing.T, repoPath string, args ...string) {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v in %s: %v\n%s", args, repoPath, err, output)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type readyOptions struct {
|
||||
runID string
|
||||
limit int
|
||||
}
|
||||
|
||||
func newReadyCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &readyOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ready",
|
||||
Short: "List tasks that are ready for dispatch",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
tasks, err := store.NewOrchStore(sqlDB).ListReadyTasks(ctx, store.ListReadyInput{
|
||||
RunID: opts.runID,
|
||||
Limit: opts.limit,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "ready",
|
||||
Data: map[string]any{
|
||||
"tasks": tasks,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), "no ready tasks")
|
||||
return err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\n", task.TaskID, task.Priority, task.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().IntVar(&opts.limit, "limit", 20, "Maximum number of tasks to list")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type reassignOptions struct {
|
||||
runID string
|
||||
taskID string
|
||||
toAgent string
|
||||
reason string
|
||||
}
|
||||
|
||||
func newReassignCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &reassignOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "reassign",
|
||||
Short: "Reassign a blocked or failed task to another worker",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
s := store.NewOrchStore(sqlDB)
|
||||
task, attempt, err := s.GetTaskWithLatestAttempt(ctx, opts.runID, opts.taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ReassignTask(ctx, store.ReassignInput{
|
||||
RunID: opts.runID,
|
||||
TaskID: opts.taskID,
|
||||
ToAgent: opts.toAgent,
|
||||
Reason: opts.reason,
|
||||
PrepareWorkspace: newAttemptReuseWorkspacePreparer(cmd, task, attempt),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "reassign",
|
||||
Data: map[string]any{
|
||||
"task": result.Task,
|
||||
"attempt": result.Attempt,
|
||||
"thread": result.Thread,
|
||||
"message": result.Message,
|
||||
"previous_attempt": result.PreviousAttempt,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "reassigned task %s to %s as attempt %d\n", result.Task.TaskID, result.Attempt.AssignedTo, result.Attempt.AttemptNo)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID")
|
||||
cmd.Flags().StringVar(&opts.toAgent, "to", "", "Destination worker agent")
|
||||
cmd.Flags().StringVar(&opts.reason, "reason", "", "Reason for reassignment")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
_ = cmd.MarkFlagRequired("task")
|
||||
_ = cmd.MarkFlagRequired("to")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type reconcileOptions struct {
|
||||
runID string
|
||||
}
|
||||
|
||||
func newReconcileCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &reconcileOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "reconcile",
|
||||
Short: "Reconcile inbox thread state back into orch task state",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
result, err := store.NewOrchStore(sqlDB).ReconcileRun(ctx, opts.runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "reconcile",
|
||||
Data: map[string]any{
|
||||
"run": result.Run,
|
||||
"task_counts": result.TaskCounts,
|
||||
"updated_tasks": result.UpdatedTasks,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "reconciled run %s (%d updated tasks)\n", result.Run.RunID, len(result.UpdatedTasks))
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type retryOptions struct {
|
||||
runID string
|
||||
taskID string
|
||||
toAgent string
|
||||
body string
|
||||
bodyFile string
|
||||
}
|
||||
|
||||
func newRetryCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &retryOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "retry",
|
||||
Short: "Retry a failed task by creating a new attempt",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
body, err := resolveBodyValue(opts.body, opts.bodyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
s := store.NewOrchStore(sqlDB)
|
||||
task, attempt, err := s.GetTaskWithLatestAttempt(ctx, opts.runID, opts.taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.RetryTask(ctx, store.RetryInput{
|
||||
RunID: opts.runID,
|
||||
TaskID: opts.taskID,
|
||||
ToAgent: opts.toAgent,
|
||||
Body: body,
|
||||
PrepareWorkspace: newAttemptReuseWorkspacePreparer(cmd, task, attempt),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "retry",
|
||||
Data: map[string]any{
|
||||
"task": result.Task,
|
||||
"attempt": result.Attempt,
|
||||
"thread": result.Thread,
|
||||
"message": result.Message,
|
||||
"previous_attempt": result.PreviousAttempt,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "retried task %s as attempt %d\n", result.Task.TaskID, result.Attempt.AttemptNo)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID")
|
||||
cmd.Flags().StringVar(&opts.toAgent, "to", "", "Optional worker agent override")
|
||||
cmd.Flags().StringVar(&opts.body, "body", "", "Retry instruction body")
|
||||
cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read retry instruction body from file")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
_ = cmd.MarkFlagRequired("task")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type rootOptions struct {
|
||||
dbPath string
|
||||
json bool
|
||||
}
|
||||
|
||||
func NewRootCmd() *cobra.Command {
|
||||
opts := &rootOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "orch",
|
||||
Short: "Leader-facing scheduler and control plane",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringVar(&opts.dbPath, "db", ".agents/coord.db", "SQLite database path")
|
||||
cmd.PersistentFlags().BoolVar(&opts.json, "json", false, "Emit machine-readable JSON")
|
||||
|
||||
cmd.AddCommand(newRunCmd(opts))
|
||||
cmd.AddCommand(newTaskCmd(opts))
|
||||
cmd.AddCommand(newDepCmd(opts))
|
||||
cmd.AddCommand(newReadyCmd(opts))
|
||||
cmd.AddCommand(newDispatchCmd(opts))
|
||||
cmd.AddCommand(newReconcileCmd(opts))
|
||||
cmd.AddCommand(newWaitCmd(opts))
|
||||
cmd.AddCommand(newRetryCmd(opts))
|
||||
cmd.AddCommand(newReassignCmd(opts))
|
||||
cmd.AddCommand(newCancelCmd(opts))
|
||||
cmd.AddCommand(newCleanupCmd(opts))
|
||||
cmd.AddCommand(newCouncilCmd(opts))
|
||||
cmd.AddCommand(newBlockedCmd(opts))
|
||||
cmd.AddCommand(newAnswerCmd(opts))
|
||||
cmd.AddCommand(newStatusCmd(opts))
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type runInitOptions struct {
|
||||
runID string
|
||||
goal string
|
||||
summary string
|
||||
}
|
||||
|
||||
type runShowOptions struct {
|
||||
runID string
|
||||
}
|
||||
|
||||
func newRunCmd(root *rootOptions) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Run management commands",
|
||||
}
|
||||
|
||||
cmd.AddCommand(newRunInitCmd(root))
|
||||
cmd.AddCommand(newRunShowCmd(root))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newRunInitCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &runInitOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Create a new orchestration run",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
run, err := store.NewOrchStore(sqlDB).CreateRun(ctx, store.CreateRunInput{
|
||||
RunID: opts.runID,
|
||||
Goal: opts.goal,
|
||||
Summary: opts.summary,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "run init",
|
||||
Data: map[string]any{
|
||||
"run": run,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "created run %s\n", run.RunID)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().StringVar(&opts.goal, "goal", "", "Run goal")
|
||||
cmd.Flags().StringVar(&opts.summary, "summary", "", "Run summary")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
_ = cmd.MarkFlagRequired("goal")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newRunShowCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &runShowOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show run metadata and aggregate state",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
overview, err := store.NewOrchStore(sqlDB).GetRunOverview(ctx, opts.runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "run show",
|
||||
Data: map[string]any{
|
||||
"run": overview.Run,
|
||||
"task_counts": overview.TaskCounts,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "run %s status %s\n", overview.Run.RunID, overview.Run.Status)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type statusOptions struct {
|
||||
runID string
|
||||
}
|
||||
|
||||
func newStatusCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &statusOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show task state summary for the run",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
overview, err := store.NewOrchStore(sqlDB).GetRunOverview(ctx, opts.runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "status",
|
||||
Data: map[string]any{
|
||||
"run": overview.Run,
|
||||
"task_counts": overview.TaskCounts,
|
||||
"tasks": overview.Tasks,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "run %s status %s\n", overview.Run.RunID, overview.Run.Status); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, task := range overview.Tasks {
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\n", task.TaskID, task.Status, task.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type taskAddOptions struct {
|
||||
runID string
|
||||
taskID string
|
||||
title string
|
||||
summary string
|
||||
defaultTo string
|
||||
acceptanceJSON string
|
||||
priority string
|
||||
}
|
||||
|
||||
func newTaskCmd(root *rootOptions) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "task",
|
||||
Short: "Task management commands",
|
||||
}
|
||||
|
||||
cmd.AddCommand(newTaskAddCmd(root))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskAddCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &taskAddOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a task to a run",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
task, err := store.NewOrchStore(sqlDB).AddTask(ctx, store.AddTaskInput{
|
||||
RunID: opts.runID,
|
||||
TaskID: opts.taskID,
|
||||
Title: opts.title,
|
||||
Summary: opts.summary,
|
||||
DefaultTo: opts.defaultTo,
|
||||
AcceptanceJSON: opts.acceptanceJSON,
|
||||
Priority: opts.priority,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "task add",
|
||||
Data: map[string]any{
|
||||
"task": task,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "added task %s to run %s\n", task.TaskID, task.RunID)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID")
|
||||
cmd.Flags().StringVar(&opts.title, "title", "", "Task title")
|
||||
cmd.Flags().StringVar(&opts.summary, "summary", "", "Task summary")
|
||||
cmd.Flags().StringVar(&opts.defaultTo, "default-to", "", "Default worker agent")
|
||||
cmd.Flags().StringVar(&opts.acceptanceJSON, "acceptance-json", "", "Acceptance criteria JSON")
|
||||
cmd.Flags().StringVar(&opts.priority, "priority", "normal", "Task priority")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
_ = cmd.MarkFlagRequired("task")
|
||||
_ = cmd.MarkFlagRequired("title")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runOrchCommand(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
stdout, stderr, exitCode := executeOrchCommand(args...)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("execute orch command %v: exit=%d\nstderr:\n%s\nstdout:\n%s", args, exitCode, stderr, stdout)
|
||||
}
|
||||
|
||||
return stdout
|
||||
}
|
||||
|
||||
func executeOrchCommand(args ...string) (string, string, int) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := Execute(args, &stdout, &stderr)
|
||||
return stdout.String(), stderr.String(), exitCode
|
||||
}
|
||||
|
||||
func runInboxCommand(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
stdout, stderr, exitCode := executeInboxCommand(args...)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("execute inbox command %v: exit=%d\nstderr:\n%s\nstdout:\n%s", args, exitCode, stderr, stdout)
|
||||
}
|
||||
|
||||
return stdout
|
||||
}
|
||||
|
||||
func executeInboxCommand(args ...string) (string, string, int) {
|
||||
cmd := exec.Command("go", append([]string{"run", inboxCommandPath()}, args...)...)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return stdout.String(), stderr.String(), 0
|
||||
}
|
||||
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
return stdout.String(), stderr.String(), exitError.ExitCode()
|
||||
}
|
||||
|
||||
return stdout.String(), stderr.String(), 1
|
||||
}
|
||||
|
||||
func inboxCommandPath() string {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
panic("unable to determine orch test helper path")
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "..", "cmd", "inbox")
|
||||
}
|
||||
|
||||
func mustDecodeJSON(t *testing.T, raw string, target any) {
|
||||
t.Helper()
|
||||
|
||||
if err := json.Unmarshal([]byte(raw), target); err != nil {
|
||||
t.Fatalf("decode json %q: %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func nestedString(t *testing.T, value map[string]any, keys ...string) string {
|
||||
t.Helper()
|
||||
|
||||
current := nestedValue(t, value, keys...)
|
||||
str, ok := current.(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected string at %v, got %#v", keys, current)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func nestedValue(t *testing.T, value map[string]any, keys ...string) any {
|
||||
t.Helper()
|
||||
|
||||
var current any = value
|
||||
for _, key := range keys {
|
||||
obj, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object at %q in %v, got %#v", key, keys, current)
|
||||
}
|
||||
current, ok = obj[key]
|
||||
if !ok {
|
||||
t.Fatalf("missing key %q in %v", key, keys)
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func nestedArray(t *testing.T, value map[string]any, keys ...string) []any {
|
||||
t.Helper()
|
||||
|
||||
current := nestedValue(t, value, keys...)
|
||||
items, ok := current.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected array at %v, got %#v", keys, current)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func assertErrorJSON(t *testing.T, raw string, expectedCode string) {
|
||||
t.Helper()
|
||||
|
||||
var payload map[string]any
|
||||
mustDecodeJSON(t, raw, &payload)
|
||||
if ok, _ := payload["ok"].(bool); ok {
|
||||
t.Fatalf("expected ok=false error payload, got %#v", payload)
|
||||
}
|
||||
errorValue, ok := payload["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected error object, got %#v", payload["error"])
|
||||
}
|
||||
if code, _ := errorValue["code"].(string); code != expectedCode {
|
||||
t.Fatalf("expected error code %q, got %#v", expectedCode, errorValue["code"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type waitOptions struct {
|
||||
runID string
|
||||
eventTypesRaw string
|
||||
afterEventID int64
|
||||
timeoutSeconds int
|
||||
}
|
||||
|
||||
func newWaitCmd(root *rootOptions) *cobra.Command {
|
||||
opts := &waitOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "wait",
|
||||
Short: "Block until matching run-scoped task events become available",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := openOrchDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
result, err := store.NewOrchStore(sqlDB).WaitForEvents(ctx, store.WaitInput{
|
||||
RunID: opts.runID,
|
||||
EventTypes: splitCommaList(opts.eventTypesRaw),
|
||||
AfterEventID: opts.afterEventID,
|
||||
Timeout: time.Duration(opts.timeoutSeconds) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "wait",
|
||||
Data: map[string]any{
|
||||
"run_id": opts.runID,
|
||||
"woke": result.Woke,
|
||||
"next_event_id": result.NextEventID,
|
||||
"events": result.Events,
|
||||
},
|
||||
}
|
||||
if root.json {
|
||||
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
|
||||
}
|
||||
|
||||
if !result.Woke {
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "wait timed out after event %d\n", result.NextEventID)
|
||||
return err
|
||||
}
|
||||
for _, event := range result.Events {
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%d\t%s\t%s\t%s\n", event.EventID, event.Type, event.TaskID, event.Summary); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
|
||||
cmd.Flags().StringVar(&opts.eventTypesRaw, "for", "task_ready,task_blocked,task_done,task_failed", "Comma-separated event types to wait for")
|
||||
cmd.Flags().Int64Var(&opts.afterEventID, "after-event", 0, "Only wait for events after this event ID")
|
||||
cmd.Flags().IntVar(&opts.timeoutSeconds, "timeout-seconds", 0, "Maximum time to wait before timing out")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func splitCommaList(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, part)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"ai-workflow-skill/packages/coord-core/protocol"
|
||||
"ai-workflow-skill/packages/coord-core/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newDispatchWorkspacePreparer(cmd *cobra.Command, opts dispatchOptions) store.DispatchWorkspacePreparer {
|
||||
ctx := cmd.Context()
|
||||
|
||||
return func(task store.Task, attemptNo int) (store.DispatchWorkspace, func(), error) {
|
||||
effectiveOpts, useWorktree := resolveDispatchWorktreeOptions(task, opts)
|
||||
if !useWorktree {
|
||||
return store.DispatchWorkspace{}, func() {}, nil
|
||||
}
|
||||
return provisionDispatchWorkspace(ctx, effectiveOpts, task, attemptNo)
|
||||
}
|
||||
}
|
||||
|
||||
func newAttemptReuseWorkspacePreparer(cmd *cobra.Command, task store.Task, attempt *store.TaskAttempt) store.DispatchWorkspacePreparer {
|
||||
if attempt == nil || attempt.WorktreePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
workspaceRoot, ok := deriveWorkspaceRootFromAttempt(task.RunID, task.TaskID, attempt.WorktreePath)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
baseRef := attempt.BaseRef
|
||||
if strings.TrimSpace(baseRef) == "" {
|
||||
baseRef = attempt.BaseCommit
|
||||
}
|
||||
|
||||
opts := dispatchOptions{
|
||||
repoPath: attempt.WorktreePath,
|
||||
workspaceRoot: workspaceRoot,
|
||||
strictWorktree: true,
|
||||
baseRef: baseRef,
|
||||
}
|
||||
|
||||
return newDispatchWorkspacePreparer(cmd, opts)
|
||||
}
|
||||
|
||||
func dispatchUsesWorktree(opts dispatchOptions) bool {
|
||||
return strings.TrimSpace(opts.workspaceRoot) != "" ||
|
||||
opts.strictWorktree
|
||||
}
|
||||
|
||||
func resolveDispatchWorktreeOptions(task store.Task, opts dispatchOptions) (dispatchOptions, bool) {
|
||||
if dispatchUsesWorktree(opts) {
|
||||
return opts, true
|
||||
}
|
||||
if !taskLooksLikeCodeWork(task) {
|
||||
return opts, false
|
||||
}
|
||||
|
||||
auto := opts
|
||||
auto.strictWorktree = true
|
||||
return auto, true
|
||||
}
|
||||
|
||||
func taskLooksLikeCodeWork(task store.Task) bool {
|
||||
if acceptanceJSONLooksCodeLike(task.AcceptanceJSON) {
|
||||
return true
|
||||
}
|
||||
return roleLooksCodeLike(task.DefaultTo)
|
||||
}
|
||||
|
||||
func acceptanceJSONLooksCodeLike(raw json.RawMessage) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return false
|
||||
}
|
||||
return acceptanceValueLooksCodeLike(value)
|
||||
}
|
||||
|
||||
func acceptanceValueLooksCodeLike(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, raw := range typed {
|
||||
lowerKey := strings.ToLower(strings.TrimSpace(key))
|
||||
switch lowerKey {
|
||||
case "code", "code_task", "writes_code", "worktree":
|
||||
if boolValue, ok := raw.(bool); ok && boolValue {
|
||||
return true
|
||||
}
|
||||
case "kind", "task_type", "mode", "type":
|
||||
if stringValue, ok := raw.(string); ok && isCodeLikeMarker(stringValue) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if acceptanceValueLooksCodeLike(raw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if acceptanceValueLooksCodeLike(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case string:
|
||||
return isCodeLikeMarker(typed)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func roleLooksCodeLike(role string) bool {
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
if role == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, token := range splitIdentifierTokens(role) {
|
||||
switch token {
|
||||
case "backend", "frontend", "front", "admin", "ui", "fullstack", "foundation", "db", "database", "mobile", "ios", "android", "web", "platform", "infra", "api":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isCodeLikeMarker(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "code", "code_task", "code-task", "code-change", "code_change", "implementation", "patch", "diff", "repo":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func splitIdentifierTokens(value string) []string {
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'))
|
||||
})
|
||||
}
|
||||
|
||||
func provisionDispatchWorkspace(ctx context.Context, opts dispatchOptions, task store.Task, attemptNo int) (store.DispatchWorkspace, func(), error) {
|
||||
repoRoot, err := resolveRepoRoot(ctx, opts.repoPath)
|
||||
if err != nil {
|
||||
return store.DispatchWorkspace{}, nil, err
|
||||
}
|
||||
|
||||
workspaceRoot := resolveWorkspaceRoot(repoRoot, opts.workspaceRoot)
|
||||
if err := ensureWorkspaceRootIgnored(repoRoot, workspaceRoot); err != nil {
|
||||
return store.DispatchWorkspace{}, nil, err
|
||||
}
|
||||
|
||||
baseRef, baseCommit, err := resolveDispatchBase(ctx, repoRoot, workspaceRoot, opts.baseRef, opts.strictWorktree)
|
||||
if err != nil {
|
||||
return store.DispatchWorkspace{}, nil, err
|
||||
}
|
||||
|
||||
branchName := buildAttemptBranchName(task.RunID, task.TaskID, attemptNo)
|
||||
worktreePath := buildAttemptWorktreePath(workspaceRoot, task.RunID, task.TaskID, attemptNo)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
|
||||
return store.DispatchWorkspace{}, nil, fmt.Errorf("create worktree parent dir: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(worktreePath); err == nil {
|
||||
return store.DispatchWorkspace{}, nil, fmt.Errorf("%w: worktree path already exists: %s", store.ErrInvalidState, worktreePath)
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return store.DispatchWorkspace{}, nil, fmt.Errorf("stat worktree path: %w", err)
|
||||
}
|
||||
|
||||
if _, _, err := runGit(ctx, repoRoot, "worktree", "add", "-b", branchName, worktreePath, baseCommit); err != nil {
|
||||
return store.DispatchWorkspace{}, nil, err
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_, _, _ = runGit(context.Background(), repoRoot, "worktree", "remove", "--force", worktreePath)
|
||||
_, _, _ = runGit(context.Background(), repoRoot, "branch", "-D", branchName)
|
||||
_ = os.RemoveAll(worktreePath)
|
||||
}
|
||||
|
||||
return store.DispatchWorkspace{
|
||||
BaseRef: baseRef,
|
||||
BaseCommit: baseCommit,
|
||||
BranchName: branchName,
|
||||
WorktreePath: worktreePath,
|
||||
WorkspaceStatus: "created",
|
||||
}, cleanup, nil
|
||||
}
|
||||
|
||||
func resolveRepoRoot(ctx context.Context, repoPath string) (string, error) {
|
||||
startPath := strings.TrimSpace(repoPath)
|
||||
if startPath == "" {
|
||||
var err error
|
||||
startPath, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get current working directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(startPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve repo path: %w", err)
|
||||
}
|
||||
|
||||
if _, _, err := runGit(ctx, absPath, "rev-parse", "--show-toplevel"); err != nil {
|
||||
return "", protocol.InvalidInput("repo-path must point to a Git worktree", err)
|
||||
}
|
||||
|
||||
commonDir, err := resolveCommonGitDir(ctx, absPath)
|
||||
if err != nil {
|
||||
return "", protocol.InvalidInput("repo-path must point to a Git worktree", err)
|
||||
}
|
||||
return filepath.Dir(commonDir), nil
|
||||
}
|
||||
|
||||
func resolveDispatchBase(ctx context.Context, repoRoot, workspaceRoot, requestedBaseRef string, strict bool) (string, string, error) {
|
||||
baseRef := strings.TrimSpace(requestedBaseRef)
|
||||
if baseRef != "" {
|
||||
baseCommit, err := resolveCommit(ctx, repoRoot, baseRef)
|
||||
if err != nil {
|
||||
return "", "", protocol.InvalidInput("base-ref must resolve to a commit", err)
|
||||
}
|
||||
return baseRef, baseCommit, nil
|
||||
}
|
||||
|
||||
if strict {
|
||||
dirty, err := repoHasUncommittedChanges(ctx, repoRoot, workspaceRoot)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if dirty {
|
||||
return "", "", fmt.Errorf("%w: repository has uncommitted changes; specify --base-ref or clean the repo", store.ErrInvalidState)
|
||||
}
|
||||
}
|
||||
|
||||
baseCommit, err := resolveCommit(ctx, repoRoot, "HEAD")
|
||||
if err != nil {
|
||||
return "", "", protocol.InvalidInput("failed to resolve HEAD commit", err)
|
||||
}
|
||||
return "HEAD", baseCommit, nil
|
||||
}
|
||||
|
||||
func resolveCommit(ctx context.Context, repoRoot, ref string) (string, error) {
|
||||
stdout, _, err := runGit(ctx, repoRoot, "rev-parse", "--verify", ref+"^{commit}")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(stdout), nil
|
||||
}
|
||||
|
||||
func repoHasUncommittedChanges(ctx context.Context, repoRoot, workspaceRoot string) (bool, error) {
|
||||
stdout, _, err := runGit(ctx, repoRoot, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check repository status: %w", err)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if len(line) >= 3 {
|
||||
path := strings.TrimSpace(line[3:])
|
||||
if shouldIgnoreStatusPath(repoRoot, workspaceRoot, path) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func resolveWorkspaceRoot(repoRoot, configuredRoot string) string {
|
||||
root := strings.TrimSpace(configuredRoot)
|
||||
if root == "" {
|
||||
return filepath.Join(repoRoot, ".orch", "worktrees")
|
||||
}
|
||||
if filepath.IsAbs(root) {
|
||||
return root
|
||||
}
|
||||
return filepath.Join(repoRoot, root)
|
||||
}
|
||||
|
||||
func ensureWorkspaceRootIgnored(repoRoot, workspaceRoot string) error {
|
||||
relative, err := filepath.Rel(repoRoot, workspaceRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve workspace root exclude path: %w", err)
|
||||
}
|
||||
if relative == "." || strings.HasPrefix(relative, "..") {
|
||||
return nil
|
||||
}
|
||||
|
||||
pattern := filepath.ToSlash(relative)
|
||||
if !strings.HasSuffix(pattern, "/") {
|
||||
pattern += "/"
|
||||
}
|
||||
|
||||
excludePath := filepath.Join(repoRoot, ".git", "info", "exclude")
|
||||
if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
|
||||
return fmt.Errorf("create git info dir: %w", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(excludePath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read git exclude file: %w", err)
|
||||
}
|
||||
if strings.Contains(string(content), pattern) {
|
||||
return nil
|
||||
}
|
||||
|
||||
appendContent := pattern + "\n"
|
||||
if len(content) > 0 && !strings.HasSuffix(string(content), "\n") {
|
||||
appendContent = "\n" + appendContent
|
||||
}
|
||||
file, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open git exclude file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := file.WriteString(appendContent); err != nil {
|
||||
return fmt.Errorf("append git exclude file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldIgnoreStatusPath(repoRoot, workspaceRoot, statusPath string) bool {
|
||||
relative, err := filepath.Rel(repoRoot, workspaceRoot)
|
||||
if err != nil || relative == "." || strings.HasPrefix(relative, "..") {
|
||||
return false
|
||||
}
|
||||
|
||||
relative = filepath.ToSlash(relative)
|
||||
statusPath = filepath.ToSlash(strings.Trim(statusPath, `"`))
|
||||
return statusPath == relative || strings.HasPrefix(statusPath, relative+"/")
|
||||
}
|
||||
|
||||
func buildAttemptBranchName(runID, taskID string, attemptNo int) string {
|
||||
return fmt.Sprintf(
|
||||
"orch/%s/%s/attempt-%d",
|
||||
sanitizeGitSegment(runID),
|
||||
sanitizeGitSegment(taskID),
|
||||
attemptNo,
|
||||
)
|
||||
}
|
||||
|
||||
func buildAttemptWorktreePath(workspaceRoot, runID, taskID string, attemptNo int) string {
|
||||
return filepath.Join(
|
||||
workspaceRoot,
|
||||
sanitizePathSegment(runID),
|
||||
sanitizePathSegment(taskID),
|
||||
fmt.Sprintf("attempt-%d", attemptNo),
|
||||
)
|
||||
}
|
||||
|
||||
func deriveWorkspaceRootFromAttempt(runID, taskID, worktreePath string) (string, bool) {
|
||||
suffix := filepath.Join(
|
||||
sanitizePathSegment(runID),
|
||||
sanitizePathSegment(taskID),
|
||||
filepath.Base(worktreePath),
|
||||
)
|
||||
parent := filepath.Dir(worktreePath)
|
||||
if filepath.Base(parent) != sanitizePathSegment(taskID) {
|
||||
return "", false
|
||||
}
|
||||
runDir := filepath.Dir(parent)
|
||||
if filepath.Base(runDir) != sanitizePathSegment(runID) {
|
||||
return "", false
|
||||
}
|
||||
root := filepath.Dir(runDir)
|
||||
if filepath.Clean(filepath.Join(root, suffix)) != filepath.Clean(worktreePath) {
|
||||
return "", false
|
||||
}
|
||||
return root, true
|
||||
}
|
||||
|
||||
func sanitizeGitSegment(value string) string {
|
||||
return sanitizeSegment(value)
|
||||
}
|
||||
|
||||
func sanitizePathSegment(value string) string {
|
||||
return sanitizeSegment(value)
|
||||
}
|
||||
|
||||
func sanitizeSegment(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "item"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if r == '-' || r == '_' || r == '.' {
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.Trim(b.String(), "-.")
|
||||
if result == "" {
|
||||
return "item"
|
||||
}
|
||||
if strings.HasSuffix(result, ".lock") {
|
||||
result = strings.TrimSuffix(result, ".lock") + "-lock"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func runGit(ctx context.Context, repoRoot string, args ...string) (string, string, error) {
|
||||
cmdArgs := append([]string{"-C", repoRoot}, args...)
|
||||
cmd := exec.CommandContext(ctx, "git", cmdArgs...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return string(output), "", nil
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return "", message, fmt.Errorf("git %s: %s", strings.Join(args, " "), message)
|
||||
}
|
||||
|
||||
func cleanupAttemptWorktree(ctx context.Context, attempt store.TaskAttempt, force bool) error {
|
||||
if strings.TrimSpace(attempt.WorktreePath) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(attempt.WorktreePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat worktree path: %w", err)
|
||||
}
|
||||
|
||||
repoRoot, err := resolveRepoRootFromExistingWorktree(ctx, attempt.WorktreePath)
|
||||
if err != nil {
|
||||
if force {
|
||||
return os.RemoveAll(attempt.WorktreePath)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
args := []string{"worktree", "remove"}
|
||||
if force {
|
||||
args = append(args, "--force")
|
||||
}
|
||||
args = append(args, attempt.WorktreePath)
|
||||
if _, _, err := runGit(ctx, repoRoot, args...); err != nil {
|
||||
if force {
|
||||
return os.RemoveAll(attempt.WorktreePath)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveRepoRootFromExistingWorktree(ctx context.Context, worktreePath string) (string, error) {
|
||||
commonDir, err := resolveCommonGitDir(ctx, worktreePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Dir(commonDir), nil
|
||||
}
|
||||
|
||||
func resolveCommonGitDir(ctx context.Context, repoPath string) (string, error) {
|
||||
stdout, _, err := runGit(ctx, repoPath, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
commonDir := strings.TrimSpace(stdout)
|
||||
if !filepath.IsAbs(commonDir) {
|
||||
commonDir = filepath.Join(repoPath, commonDir)
|
||||
}
|
||||
return filepath.Clean(commonDir), nil
|
||||
}
|
||||
Reference in New Issue
Block a user