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
+1 -3
View File
@@ -7,7 +7,5 @@ import (
)
func main() {
if err := orchcli.NewRootCmd().Execute(); err != nil {
os.Exit(1)
}
os.Exit(orchcli.Execute(os.Args[1:], os.Stdout, os.Stderr))
}
+37 -8
View File
@@ -24,10 +24,11 @@ As of now:
- a reusable Codex skill package for `inbox` now exists under `skills/inbox/`, with a formal `SKILL.md`, `agents/openai.yaml`, and a bundled CLI binary asset
- an inbox skill forward-test plan directory now exists under `docs/tests/inbox-skill/`, with a shared execution template and multiple scenario cases
- an execution-roadmap workflow now exists under `docs/roadmaps/active/` and `docs/roadmaps/archive/` for agent-level work traces and completion archives
- `orch` currently exists as a command skeleton only
- no scheduler workflows have been implemented yet
- `orch` now implements `run init/show`, `task add`, `dep add`, `ready`, `dispatch`, `reconcile`, `blocked`, `answer`, and `status`
- `orch` can create runs, gate tasks through dependencies, dispatch work through `inbox`, reconcile worker thread state back into task state, and answer blocked tasks
- automated integration tests now cover the main `orch` scheduler slice, including dependency gating, dispatch, blocked-answer flow, and reconcile
This means the project is past design discovery and ready for `orch` implementation.
This means the project now has a working `orch` core scheduler and is ready for strict worktree-backed execution support.
## Source Of Truth
@@ -67,9 +68,10 @@ Current implementation status:
- `Milestone 1: Go Skeleton` is complete
- `Milestone 2: Shared DB Layer` is complete enough for both CLIs
- `Milestone 3: Inbox Happy Path` is complete
- `Milestone 4: Orch Core Scheduling` is complete for the current non-worktree scheduler scope
- `Milestone 6: Waiting Primitives` is partially complete through `inbox wait-reply`
The next practical coding target is `Milestone 4: Orch Core Scheduling`.
The next practical coding target is `Milestone 5: Strict Worktree Support`.
### Milestone 1: Go Skeleton
@@ -217,6 +219,30 @@ Definition of done:
- dispatch a task through `orch`
- see worker state reflected back after `reconcile`
Status:
- completed for the current non-worktree scheduling scope
Completed so far:
- `orch run init`
- `orch run show`
- `orch task add`
- `orch dep add`
- `orch ready`
- `orch dispatch`
- `orch reconcile`
- `orch blocked`
- `orch answer`
- `orch status`
- CLI integration tests cover single-task dispatch/reconcile, dependency gating, blocked-answer flow, and non-ready dispatch rejection
Remaining:
- strict worktree provisioning on dispatch
- `orch wait`
- retry, reassign, cancel, and cleanup workflows
### Milestone 5: Strict Worktree Support
Goal:
@@ -273,10 +299,11 @@ Definition of done:
If a new agent is taking over now, the next concrete step should be:
1. start `Milestone 4: Orch Core Scheduling`
2. keep the authored inbox test-plan set in `docs/tests/inbox/` synchronized if CLI behavior changes during `orch` work
1. start `Milestone 5: Strict Worktree Support`
2. add real worktree metadata population to `orch dispatch`
3. keep the authored inbox test-plan set in `docs/tests/inbox/` synchronized if CLI behavior changes during further `orch` work
The inbox implementation and its human-readable test-plan set are already in place, so the next meaningful project step is to build scheduler behavior on top of that stable base.
The inbox implementation and its human-readable test-plan set are already in place, and the initial `orch` scheduler loop now exists, so the next meaningful project step is to isolate code-writing attempts in real worktrees.
## Recommended Driver Choices
@@ -297,11 +324,13 @@ Completed so far:
- schema init test
- inbox command-level CLI integration coverage aligned to `docs/tests/inbox/`
- inbox workflow lifecycle coverage
- orch scheduler lifecycle coverage for run/task/dependency/dispatch/reconcile
- orch blocked-question and answer coverage
Still recommended before the codebase grows too much:
- single-task orch dispatch and reconcile test
- worktree path generation test
- `orch wait` event wake test
- council tally grouping test
## Inbox Test Documentation Roadmap
@@ -0,0 +1,64 @@
# Orch Core Scheduling
## Status
- `completed`
## Owner
- codex
## Started At
- `2026-03-19`
## Goal
- implement the first usable `orch` scheduling slice on top of the existing shared SQLite schema and `inbox` transport
- deliver a leader workflow that can create a run, add tasks and dependencies, dispatch ready work, reconcile inbox state, and answer blocked tasks
## Scope
- add `orch` store primitives for runs, tasks, dependencies, attempts, readiness, dispatch, reconcile, blocked lookup, and run status views
- add CLI commands for the first Milestone 4 surface
- add automated tests for the happy-path scheduler workflow and core state transitions
- update the implementation roadmap with the new progress state
## Checklist
- [x] inspect the current `orch` skeleton, schema, and project roadmaps
- [x] implement `orch` store types and DB operations for runs, tasks, dependencies, attempts, and task-state transitions
- [x] add CLI commands for `run init`, `run show`, `task add`, `dep add`, `ready`, `dispatch`, `reconcile`, `blocked`, `answer`, and `status`
- [x] add automated tests covering run creation, dependency gating, dispatch, blocked-answer flow, and reconcile
- [x] run `go test ./...`
- [x] update `docs/implementation-roadmap.md`
- [x] archive this roadmap with a completion summary when the workstream is complete
## Files
- `docs/roadmaps/active/orch-core-scheduling.md`
- `docs/implementation-roadmap.md`
- `cmd/orch/main.go`
- `internal/cli/orch/root.go`
- `internal/cli/orch/*.go`
- `internal/store/orch.go`
- `internal/cli/orch/*_test.go`
## Decisions
- start with the scheduler loop that reuses existing `inbox` behavior rather than attempting worktree orchestration in the same slice
- keep JSON response style aligned with `inbox` so both CLIs expose consistent machine-readable contracts
## Blockers
- none
## Next Step
- start `Milestone 5: Strict Worktree Support` by extending `orch dispatch` to resolve a concrete base commit and create real worktree metadata
## Completion Summary
- `orch` now has a usable core scheduler loop backed by shared SQLite state and `inbox`
- the implemented CLI surface covers `run init/show`, `task add`, `dep add`, `ready`, `dispatch`, `reconcile`, `blocked`, `answer`, and `status`
- integration tests now verify dispatch/reconcile lifecycle, dependency gating, blocked-question answers, and non-ready dispatch rejection
+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