Implement orch core scheduling
This commit is contained in:
+1
-3
@@ -7,7 +7,5 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := orchcli.NewRootCmd().Execute(); err != nil {
|
os.Exit(orchcli.Execute(os.Args[1:], os.Stdout, os.Stderr))
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
- 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 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
|
- 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
|
- `orch` now implements `run init/show`, `task add`, `dep add`, `ready`, `dispatch`, `reconcile`, `blocked`, `answer`, and `status`
|
||||||
- no scheduler workflows have been implemented yet
|
- `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
|
## Source Of Truth
|
||||||
|
|
||||||
@@ -67,9 +68,10 @@ Current implementation status:
|
|||||||
- `Milestone 1: Go Skeleton` is complete
|
- `Milestone 1: Go Skeleton` is complete
|
||||||
- `Milestone 2: Shared DB Layer` is complete enough for both CLIs
|
- `Milestone 2: Shared DB Layer` is complete enough for both CLIs
|
||||||
- `Milestone 3: Inbox Happy Path` is complete
|
- `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`
|
- `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
|
### Milestone 1: Go Skeleton
|
||||||
|
|
||||||
@@ -217,6 +219,30 @@ Definition of done:
|
|||||||
- dispatch a task through `orch`
|
- dispatch a task through `orch`
|
||||||
- see worker state reflected back after `reconcile`
|
- 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
|
### Milestone 5: Strict Worktree Support
|
||||||
|
|
||||||
Goal:
|
Goal:
|
||||||
@@ -273,10 +299,11 @@ Definition of done:
|
|||||||
|
|
||||||
If a new agent is taking over now, the next concrete step should be:
|
If a new agent is taking over now, the next concrete step should be:
|
||||||
|
|
||||||
1. start `Milestone 4: Orch Core Scheduling`
|
1. start `Milestone 5: Strict Worktree Support`
|
||||||
2. keep the authored inbox test-plan set in `docs/tests/inbox/` synchronized if CLI behavior changes during `orch` work
|
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
|
## Recommended Driver Choices
|
||||||
|
|
||||||
@@ -297,11 +324,13 @@ Completed so far:
|
|||||||
- schema init test
|
- schema init test
|
||||||
- inbox command-level CLI integration coverage aligned to `docs/tests/inbox/`
|
- inbox command-level CLI integration coverage aligned to `docs/tests/inbox/`
|
||||||
- inbox workflow lifecycle coverage
|
- 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:
|
Still recommended before the codebase grows too much:
|
||||||
|
|
||||||
- single-task orch dispatch and reconcile test
|
|
||||||
- worktree path generation test
|
- worktree path generation test
|
||||||
|
- `orch wait` event wake test
|
||||||
- council tally grouping test
|
- council tally grouping test
|
||||||
|
|
||||||
## Inbox Test Documentation Roadmap
|
## 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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -15,12 +15,22 @@ func NewRootCmd() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "orch",
|
Use: "orch",
|
||||||
Short: "Leader-facing scheduler and control plane",
|
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().StringVar(&opts.dbPath, "db", ".agents/coord.db", "SQLite database path")
|
||||||
cmd.PersistentFlags().BoolVar(&opts.json, "json", false, "Emit machine-readable JSON")
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-9
@@ -1,20 +1,124 @@
|
|||||||
package orch
|
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{
|
cmd := &cobra.Command{
|
||||||
Use: "run",
|
Use: "run",
|
||||||
Short: "Run management commands",
|
Short: "Run management commands",
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(&cobra.Command{
|
cmd.AddCommand(newRunInitCmd(root))
|
||||||
Use: "init",
|
cmd.AddCommand(newRunShowCmd(root))
|
||||||
Short: "Stub for future run initialization",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
return cmd
|
||||||
return cmd.Help()
|
}
|
||||||
},
|
|
||||||
})
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user