Implement orch core scheduling

This commit is contained in:
2026-03-19 13:13:36 +08:00
parent b110bb24d9
commit 07f4a6fdae
19 changed files with 3230 additions and 23 deletions
+77
View File
@@ -0,0 +1,77 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/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
}
+64
View File
@@ -0,0 +1,64 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/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
}
+22
View File
@@ -0,0 +1,22 @@
package orch
import (
"os"
"ai-workflow-skill/internal/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
}
+22
View File
@@ -0,0 +1,22 @@
package orch
import (
"context"
"database/sql"
"ai-workflow-skill/internal/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
}
+76
View File
@@ -0,0 +1,76 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/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
}
+94
View File
@@ -0,0 +1,94 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/store"
"github.com/spf13/cobra"
)
type dispatchOptions struct {
runID string
taskID string
toAgent string
body string
bodyFile string
baseRef 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 {
if opts.workspaceRoot != "" || opts.strictWorktree {
return protocol.InvalidInput("worktree dispatch is not implemented yet", nil)
}
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,
})
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.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
}
+113
View File
@@ -0,0 +1,113 @@
package orch
import (
"errors"
"fmt"
"io"
"strings"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/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 ")
}
+506
View File
@@ -0,0 +1,506 @@
package orch
import (
"path/filepath"
"testing"
)
func TestOrchRunDispatchReconcileLifecycle(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",
)
taskOut := 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",
)
var taskResp map[string]any
mustDecodeJSON(t, taskOut, &taskResp)
if got := nestedString(t, taskResp, "data", "task", "status"); got != "ready" {
t.Fatalf("expected new task to become ready, got %q", got)
}
readyOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"ready",
"--run", "run_blog_001",
)
var readyResp map[string]any
mustDecodeJSON(t, readyOut, &readyResp)
readyTasks := nestedArray(t, readyResp, "data", "tasks")
if len(readyTasks) != 1 {
t.Fatalf("expected one ready task, got %#v", readyTasks)
}
readyTask, ok := readyTasks[0].(map[string]any)
if !ok {
t.Fatalf("expected ready task object, got %#v", readyTasks[0])
}
if taskID, _ := readyTask["task_id"].(string); taskID != "T1" {
t.Fatalf("expected ready task T1, got %#v", readyTask["task_id"])
}
dispatchOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"dispatch",
"--run", "run_blog_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, got %q", got)
}
threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id")
runInboxCommand(
t,
"--db", dbPath,
"--json",
"claim",
"--agent", "worker-a",
"--thread", threadID,
)
runInboxCommand(
t,
"--db", dbPath,
"--json",
"update",
"--agent", "worker-a",
"--thread", threadID,
"--status", "in_progress",
"--summary", "Implementation started",
)
reconcileOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"reconcile",
"--run", "run_blog_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 running reconcile, got %#v", updatedTasks)
}
runningTask, ok := updatedTasks[0].(map[string]any)
if !ok {
t.Fatalf("expected updated task object, got %#v", updatedTasks[0])
}
if status, _ := runningTask["status"].(string); status != "running" {
t.Fatalf("expected running task after reconcile, got %#v", runningTask["status"])
}
runInboxCommand(
t,
"--db", dbPath,
"--json",
"done",
"--agent", "worker-a",
"--thread", threadID,
"--summary", "Retry policy implemented",
"--body", "The HTTP client now retries transient failures.",
)
reconcileDoneOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"reconcile",
"--run", "run_blog_001",
)
var reconcileDoneResp map[string]any
mustDecodeJSON(t, reconcileDoneOut, &reconcileDoneResp)
updatedTasks = nestedArray(t, reconcileDoneResp, "data", "updated_tasks")
if len(updatedTasks) != 1 {
t.Fatalf("expected one updated task after done reconcile, got %#v", updatedTasks)
}
doneTask, ok := updatedTasks[0].(map[string]any)
if !ok {
t.Fatalf("expected updated task object, got %#v", updatedTasks[0])
}
if status, _ := doneTask["status"].(string); status != "done" {
t.Fatalf("expected done task after reconcile, got %#v", doneTask["status"])
}
statusOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"status",
"--run", "run_blog_001",
)
var statusResp map[string]any
mustDecodeJSON(t, statusOut, &statusResp)
if got := nestedString(t, statusResp, "data", "run", "status"); got != "done" {
t.Fatalf("expected run status done, got %q", got)
}
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["status"].(string); got != "done" {
t.Fatalf("expected status task done, got %#v", task["status"])
}
}
func TestOrchDependencyBlockedAndAnswerFlow(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "coord.db")
runOrchCommand(
t,
"--db", dbPath,
"--json",
"run", "init",
"--run", "run_blog_002",
"--goal", "Build dependency-aware workflow",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"task", "add",
"--run", "run_blog_002",
"--task", "T1",
"--title", "Build backend",
"--summary", "Implement backend APIs",
"--default-to", "worker-a",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"task", "add",
"--run", "run_blog_002",
"--task", "T2",
"--title", "Build frontend",
"--summary", "Implement frontend flows",
"--default-to", "worker-b",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"dep", "add",
"--run", "run_blog_002",
"--task", "T2",
"--depends-on", "T1",
)
readyOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"ready",
"--run", "run_blog_002",
)
var readyResp map[string]any
mustDecodeJSON(t, readyOut, &readyResp)
readyTasks := nestedArray(t, readyResp, "data", "tasks")
if len(readyTasks) != 1 {
t.Fatalf("expected only dependency-free task ready, got %#v", readyTasks)
}
readyTask, ok := readyTasks[0].(map[string]any)
if !ok {
t.Fatalf("expected ready task object, got %#v", readyTasks[0])
}
if taskID, _ := readyTask["task_id"].(string); taskID != "T1" {
t.Fatalf("expected T1 ready before dependency clears, got %#v", readyTask["task_id"])
}
dispatchBackendOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"dispatch",
"--run", "run_blog_002",
"--task", "T1",
)
var dispatchBackendResp map[string]any
mustDecodeJSON(t, dispatchBackendOut, &dispatchBackendResp)
threadBackend := nestedString(t, dispatchBackendResp, "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_002",
)
readyAfterDoneOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"ready",
"--run", "run_blog_002",
)
var readyAfterDoneResp map[string]any
mustDecodeJSON(t, readyAfterDoneOut, &readyAfterDoneResp)
readyTasks = nestedArray(t, readyAfterDoneResp, "data", "tasks")
if len(readyTasks) != 1 {
t.Fatalf("expected dependent task to become ready, got %#v", readyTasks)
}
readyTask, ok = readyTasks[0].(map[string]any)
if !ok {
t.Fatalf("expected ready task object, got %#v", readyTasks[0])
}
if taskID, _ := readyTask["task_id"].(string); taskID != "T2" {
t.Fatalf("expected T2 ready after T1 completion, got %#v", readyTask["task_id"])
}
dispatchFrontendOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"dispatch",
"--run", "run_blog_002",
"--task", "T2",
)
var dispatchFrontendResp map[string]any
mustDecodeJSON(t, dispatchFrontendOut, &dispatchFrontendResp)
threadFrontend := nestedString(t, dispatchFrontendResp, "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_002",
)
blockedOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"blocked",
"--run", "run_blog_002",
)
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])
}
question, ok := blockedTask["question"].(map[string]any)
if !ok {
t.Fatalf("expected blocked question object, got %#v", blockedTask["question"])
}
if kind, _ := question["kind"].(string); kind != "question" {
t.Fatalf("expected blocked question kind, got %#v", question["kind"])
}
answerOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"answer",
"--run", "run_blog_002",
"--task", "T2",
"--body", "Use stdout for MVP.",
)
var answerResp map[string]any
mustDecodeJSON(t, answerOut, &answerResp)
if got := nestedString(t, answerResp, "data", "message", "kind"); got != "answer" {
t.Fatalf("expected answer message kind, got %q", got)
}
showOut := runInboxCommand(
t,
"--db", dbPath,
"--json",
"show",
"--thread", threadFrontend,
)
var showResp map[string]any
mustDecodeJSON(t, showOut, &showResp)
messages := nestedArray(t, showResp, "data", "messages")
if len(messages) < 4 {
t.Fatalf("expected answer to append a message, got %#v", messages)
}
lastMessage, ok := messages[len(messages)-1].(map[string]any)
if !ok {
t.Fatalf("expected last message object, got %#v", messages[len(messages)-1])
}
if kind, _ := lastMessage["kind"].(string); kind != "answer" {
t.Fatalf("expected latest message to be answer, got %#v", lastMessage["kind"])
}
runInboxCommand(
t,
"--db", dbPath,
"--json",
"update",
"--agent", "worker-b",
"--thread", threadFrontend,
"--status", "in_progress",
"--summary", "Decision applied",
)
runInboxCommand(
t,
"--db", dbPath,
"--json",
"done",
"--agent", "worker-b",
"--thread", threadFrontend,
"--summary", "Frontend complete",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"reconcile",
"--run", "run_blog_002",
)
statusOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"status",
"--run", "run_blog_002",
)
var statusResp map[string]any
mustDecodeJSON(t, statusOut, &statusResp)
if got := nestedString(t, statusResp, "data", "run", "status"); got != "done" {
t.Fatalf("expected run status done after both tasks, got %q", got)
}
}
func TestOrchDispatchRejectsNonReadyTask(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 ready gating",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"task", "add",
"--run", "run_blog_003",
"--task", "T1",
"--title", "Backend",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"task", "add",
"--run", "run_blog_003",
"--task", "T2",
"--title", "Frontend",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"dep", "add",
"--run", "run_blog_003",
"--task", "T2",
"--depends-on", "T1",
)
stdout, _, exitCode := executeOrchCommand(
"--db", dbPath,
"--json",
"dispatch",
"--run", "run_blog_003",
"--task", "T2",
)
if exitCode != 30 {
t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
}
assertErrorJSON(t, stdout, "invalid_state")
}
+69
View File
@@ -0,0 +1,69 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/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
}
+58
View File
@@ -0,0 +1,58 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/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
}
+13 -3
View File
@@ -13,14 +13,24 @@ func NewRootCmd() *cobra.Command {
opts := &rootOptions{}
cmd := &cobra.Command{
Use: "orch",
Short: "Leader-facing scheduler and control plane",
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())
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(newBlockedCmd(opts))
cmd.AddCommand(newAnswerCmd(opts))
cmd.AddCommand(newStatusCmd(opts))
return cmd
}
+113 -9
View File
@@ -1,20 +1,124 @@
package orch
import "github.com/spf13/cobra"
import (
"fmt"
func newRunCmd() *cobra.Command {
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/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(&cobra.Command{
Use: "init",
Short: "Stub for future run initialization",
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
})
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
}
+65
View File
@@ -0,0 +1,65 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/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
}
+88
View File
@@ -0,0 +1,88 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/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
}
+109
View File
@@ -0,0 +1,109 @@
package orch
import (
"bytes"
"encoding/json"
"testing"
inboxcli "ai-workflow-skill/internal/cli/inbox"
)
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) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := inboxcli.Execute(args, &stdout, &stderr)
return stdout.String(), stderr.String(), exitCode
}
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"])
}
}
File diff suppressed because it is too large Load Diff