Add orch strict worktree dispatch
This commit is contained in:
@@ -25,10 +25,11 @@ As of now:
|
||||
- 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` 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
|
||||
- `orch` can create runs, gate tasks through dependencies, dispatch work through `inbox`, reconcile worker thread state back into task state, answer blocked tasks, and create per-attempt Git worktrees during strict dispatch
|
||||
- `orch dispatch` now supports `--repo-path`, `--workspace-root`, and `--strict-worktree`, resolves committed base revisions, records workspace metadata on attempts, and writes that metadata into inbox task payloads
|
||||
- automated integration tests now cover the main `orch` scheduler slice, including dependency gating, dispatch, blocked-answer flow, reconcile, strict worktree creation, and dirty-repo rejection rules
|
||||
|
||||
This means the project now has a working `orch` core scheduler and is ready for strict worktree-backed execution support.
|
||||
This means the project now has a working `orch` core scheduler plus strict worktree-backed dispatch, and is ready for leader-side wait/retry/reassign follow-on work.
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
@@ -69,9 +70,10 @@ Current implementation status:
|
||||
- `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 5: Strict Worktree Support` is complete for the current explicit dispatch worktree mode
|
||||
- `Milestone 6: Waiting Primitives` is partially complete through `inbox wait-reply`
|
||||
|
||||
The next practical coding target is `Milestone 5: Strict Worktree Support`.
|
||||
The next practical coding target is `Milestone 6: Waiting Primitives`.
|
||||
|
||||
### Milestone 1: Go Skeleton
|
||||
|
||||
@@ -239,7 +241,6 @@ Completed so far:
|
||||
|
||||
Remaining:
|
||||
|
||||
- strict worktree provisioning on dispatch
|
||||
- `orch wait`
|
||||
- retry, reassign, cancel, and cleanup workflows
|
||||
|
||||
@@ -260,6 +261,24 @@ Definition of done:
|
||||
- a code task dispatch creates a real worktree
|
||||
- the assigned worktree path appears in attempt metadata and inbox payload
|
||||
|
||||
Status:
|
||||
|
||||
- completed for the current explicit `orch dispatch` worktree mode
|
||||
|
||||
Completed so far:
|
||||
|
||||
- `orch dispatch` can use `--repo-path` to target a source Git repository without relying on the caller's current working directory
|
||||
- `orch dispatch --strict-worktree` resolves `base_ref` to a concrete commit, defaults to `HEAD` on clean repositories, and rejects dirty repositories when `--base-ref` is omitted
|
||||
- dispatch creates a fresh branch and Git worktree per attempt and persists `base_ref`, `base_commit`, `branch_name`, `worktree_path`, and `workspace_status`
|
||||
- dispatch writes workspace metadata into the inbox task payload for worker runtimes
|
||||
- reconcile now advances `workspace_status` from `created` to `active`, `completed`, or `abandoned` based on thread state
|
||||
- CLI integration tests cover strict worktree creation, explicit-base dispatch on dirty repos, and strict dirty-repo rejection
|
||||
|
||||
Remaining:
|
||||
|
||||
- automatic code-task detection so worktree mode can be selected without explicit flags
|
||||
- `orch cleanup` for removing completed or abandoned worktrees
|
||||
|
||||
### Milestone 6: Waiting Primitives
|
||||
|
||||
Goal:
|
||||
@@ -299,11 +318,11 @@ Definition of done:
|
||||
|
||||
If a new agent is taking over now, the next concrete step should be:
|
||||
|
||||
1. start `Milestone 5: Strict Worktree Support`
|
||||
2. add real worktree metadata population to `orch dispatch`
|
||||
1. start `Milestone 6: Waiting Primitives`
|
||||
2. implement `orch wait` over the run-scoped event stream
|
||||
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, and the initial `orch` scheduler loop now exists, so the next meaningful project step is to isolate code-writing attempts in real worktrees.
|
||||
The inbox implementation and its human-readable test-plan set are already in place, and `orch` now supports strict worktree-backed dispatch, so the next meaningful project step is to give the leader a blocking wait primitive and finish the remaining scheduler controls.
|
||||
|
||||
## Recommended Driver Choices
|
||||
|
||||
@@ -326,6 +345,7 @@ Completed so far:
|
||||
- inbox workflow lifecycle coverage
|
||||
- orch scheduler lifecycle coverage for run/task/dependency/dispatch/reconcile
|
||||
- orch blocked-question and answer coverage
|
||||
- orch strict worktree creation and dirty-repo policy coverage
|
||||
|
||||
Still recommended before the codebase grows too much:
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ Suggested flags:
|
||||
- `--run RUN_ID`
|
||||
- `--task TASK_ID`
|
||||
- `--to AGENT`
|
||||
- `--repo-path PATH`
|
||||
- `--base-ref REF`
|
||||
- `--workspace-root PATH`
|
||||
- `--strict-worktree`
|
||||
@@ -186,6 +187,7 @@ Suggested flags:
|
||||
Behavior:
|
||||
|
||||
- creates a new attempt
|
||||
- resolves the source repository from `--repo-path` or the current working directory
|
||||
- resolves a committed base revision
|
||||
- creates a branch and worktree for the attempt when the task writes code
|
||||
- creates or links an `inbox` thread
|
||||
@@ -197,6 +199,7 @@ Strict-mode recommendation:
|
||||
- if `--base-ref` is omitted and the repository is clean, default to `HEAD`
|
||||
- if `--base-ref` is omitted and the repository is dirty, fail dispatch
|
||||
- if `--base-ref` is provided, resolve it to a commit and use it exactly
|
||||
- if `--workspace-root` is omitted in worktree mode, default to `.orch/worktrees` under the source repository
|
||||
|
||||
### `orch reconcile`
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Orch Strict Worktree Support
|
||||
|
||||
## Status
|
||||
|
||||
- `completed`
|
||||
|
||||
## Owner
|
||||
|
||||
- codex
|
||||
|
||||
## Started At
|
||||
|
||||
- `2026-03-19`
|
||||
|
||||
## Goal
|
||||
|
||||
- implement dispatch-time strict worktree support so `orch` can resolve a committed base, create a branch and worktree per attempt, and persist workspace metadata into attempt storage and inbox payload
|
||||
|
||||
## Scope
|
||||
|
||||
- extend `orch dispatch` to support Git-backed worktree creation
|
||||
- enforce strict-mode base resolution and dirty-repo checks
|
||||
- persist `base_ref`, `base_commit`, `branch_name`, `worktree_path`, and `workspace_status`
|
||||
- add integration tests for successful worktree dispatch and strict-mode rejection on dirty repositories
|
||||
- update project-level implementation roadmap after the work is validated
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] inspect current worktree design docs and dispatch constraints
|
||||
- [x] implement git helpers for repo discovery, base resolution, branch naming, and worktree creation
|
||||
- [x] wire worktree metadata into `orch dispatch` attempt storage and inbox payload
|
||||
- [x] add integration coverage for strict worktree success and dirty-repo failure
|
||||
- [x] run `go test ./...`
|
||||
- [x] update `docs/implementation-roadmap.md`
|
||||
- [x] archive this roadmap with completion summary
|
||||
|
||||
## Files
|
||||
|
||||
- `docs/roadmaps/archive/orch-strict-worktree-support.md`
|
||||
- `docs/implementation-roadmap.md`
|
||||
- `internal/cli/orch/dispatch.go`
|
||||
- `internal/cli/orch/integration_test.go`
|
||||
- `internal/cli/orch/worktree.go`
|
||||
- `internal/store/orch.go`
|
||||
|
||||
## Decisions
|
||||
|
||||
- use the current working directory as the default source repo for `orch dispatch`, with an explicit override flag only if implementation needs it
|
||||
- keep worktree creation tied to dispatch so failed workspace creation cannot silently leave a task marked as dispatched
|
||||
|
||||
## Blockers
|
||||
|
||||
- none
|
||||
|
||||
## Next Step
|
||||
|
||||
- implement `orch wait`, then continue with retry/reassign/cancel/cleanup on top of the now worktree-aware dispatch model
|
||||
|
||||
## Completion Summary
|
||||
|
||||
- `orch dispatch` now supports strict Git-backed worktree provisioning with repo discovery, committed base resolution, branch naming, and per-attempt worktree paths
|
||||
- dispatch persists workspace metadata on attempts and includes the same metadata in inbox task payloads for worker runtimes
|
||||
- integration tests now cover successful strict worktree creation, dirty-repo rejection without `--base-ref`, and explicit-base dispatch on dirty repositories
|
||||
@@ -60,6 +60,7 @@ This keeps worker execution reproducible and avoids hidden divergence from the l
|
||||
|
||||
For one task attempt, `orch` should:
|
||||
|
||||
- resolve the source repository from the caller context or an explicit repo-path override
|
||||
- pick a `base_ref`
|
||||
- create an attempt record
|
||||
- choose a branch name
|
||||
|
||||
@@ -16,6 +16,7 @@ type dispatchOptions struct {
|
||||
body string
|
||||
bodyFile string
|
||||
baseRef string
|
||||
repoPath string
|
||||
workspaceRoot string
|
||||
strictWorktree bool
|
||||
}
|
||||
@@ -27,10 +28,6 @@ func newDispatchCmd(root *rootOptions) *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
|
||||
@@ -44,11 +41,12 @@ func newDispatchCmd(root *rootOptions) *cobra.Command {
|
||||
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,
|
||||
RunID: opts.runID,
|
||||
TaskID: opts.taskID,
|
||||
ToAgent: opts.toAgent,
|
||||
Body: body,
|
||||
BaseRef: opts.baseRef,
|
||||
PrepareWorkspace: newDispatchWorkspacePreparer(cmd, *opts),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -85,6 +83,7 @@ func newDispatchCmd(root *rootOptions) *cobra.Command {
|
||||
cmd.Flags().StringVar(&opts.body, "body", "", "Task message body")
|
||||
cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read task message body from file")
|
||||
cmd.Flags().StringVar(&opts.baseRef, "base-ref", "", "Optional base ref to record on the attempt")
|
||||
cmd.Flags().StringVar(&opts.repoPath, "repo-path", "", "Source repository path for worktree dispatch")
|
||||
cmd.Flags().StringVar(&opts.workspaceRoot, "workspace-root", "", "Workspace root for worktree dispatch")
|
||||
cmd.Flags().BoolVar(&opts.strictWorktree, "strict-worktree", false, "Require strict worktree setup")
|
||||
_ = cmd.MarkFlagRequired("run")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func initGitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
repoPath := filepath.Join(t.TempDir(), "repo")
|
||||
if err := os.MkdirAll(repoPath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir repo path: %v", err)
|
||||
}
|
||||
|
||||
runGitCommand(t, repoPath, "init")
|
||||
runGitCommand(t, repoPath, "config", "user.email", "test@example.com")
|
||||
runGitCommand(t, repoPath, "config", "user.name", "Test User")
|
||||
|
||||
readmePath := filepath.Join(repoPath, "README.md")
|
||||
if err := os.WriteFile(readmePath, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("write README.md: %v", err)
|
||||
}
|
||||
|
||||
runGitCommand(t, repoPath, "add", "README.md")
|
||||
runGitCommand(t, repoPath, "commit", "-m", "init")
|
||||
|
||||
return repoPath
|
||||
}
|
||||
|
||||
func gitHeadCommit(t *testing.T, repoPath string) string {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("git", "-C", repoPath, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git rev-parse HEAD in %s: %v\n%s", repoPath, err, output)
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func runGitCommand(t *testing.T, repoPath string, args ...string) {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v in %s: %v\n%s", args, repoPath, err, output)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -504,3 +505,224 @@ func TestOrchDispatchRejectsNonReadyTask(t *testing.T) {
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_state")
|
||||
}
|
||||
|
||||
func TestOrchDispatchCreatesStrictWorktree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
repoPath := initGitRepo(t)
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_worktree_001",
|
||||
"--goal", "Validate strict worktree dispatch",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_worktree_001",
|
||||
"--task", "T1",
|
||||
"--title", "Implement backend",
|
||||
"--default-to", "worker-a",
|
||||
)
|
||||
|
||||
dispatchOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_worktree_001",
|
||||
"--task", "T1",
|
||||
"--repo-path", repoPath,
|
||||
"--workspace-root", ".orch/worktrees",
|
||||
"--strict-worktree",
|
||||
"--body", "Implement inside isolated worktree.",
|
||||
)
|
||||
|
||||
var dispatchResp map[string]any
|
||||
mustDecodeJSON(t, dispatchOut, &dispatchResp)
|
||||
attempt, ok := nestedValue(t, dispatchResp, "data", "attempt").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected attempt object, got %#v", nestedValue(t, dispatchResp, "data", "attempt"))
|
||||
}
|
||||
|
||||
if got, _ := attempt["base_ref"].(string); got != "HEAD" {
|
||||
t.Fatalf("expected base_ref HEAD, got %#v", attempt["base_ref"])
|
||||
}
|
||||
expectedCommit := gitHeadCommit(t, repoPath)
|
||||
if got, _ := attempt["base_commit"].(string); got != expectedCommit {
|
||||
t.Fatalf("expected base_commit %q, got %#v", expectedCommit, attempt["base_commit"])
|
||||
}
|
||||
if got, _ := attempt["branch_name"].(string); got != "orch/run-blog-worktree-001/T1/attempt-1" {
|
||||
t.Fatalf("unexpected branch name %#v", attempt["branch_name"])
|
||||
}
|
||||
|
||||
worktreePath, _ := attempt["worktree_path"].(string)
|
||||
if worktreePath == "" {
|
||||
t.Fatalf("expected worktree_path, got %#v", attempt["worktree_path"])
|
||||
}
|
||||
if got, _ := attempt["workspace_status"].(string); got != "created" {
|
||||
t.Fatalf("expected workspace_status created, got %#v", attempt["workspace_status"])
|
||||
}
|
||||
|
||||
if _, err := os.Stat(worktreePath); err != nil {
|
||||
t.Fatalf("stat worktree path %s: %v", worktreePath, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(worktreePath, "README.md")); err != nil {
|
||||
t.Fatalf("expected README.md in worktree: %v", err)
|
||||
}
|
||||
|
||||
message, ok := nestedValue(t, dispatchResp, "data", "message").(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected message object, got %#v", nestedValue(t, dispatchResp, "data", "message"))
|
||||
}
|
||||
payload, ok := message["payload_json"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected payload_json object, got %#v", message["payload_json"])
|
||||
}
|
||||
if got, _ := payload["worktree_path"].(string); got != worktreePath {
|
||||
t.Fatalf("expected payload worktree path %q, got %#v", worktreePath, payload["worktree_path"])
|
||||
}
|
||||
|
||||
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", "Started inside worktree",
|
||||
)
|
||||
|
||||
reconcileOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", "run_blog_worktree_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 worktree reconcile, got %#v", updatedTasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchStrictWorktreeRejectsDirtyRepoWithoutBaseRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
repoPath := initGitRepo(t)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(repoPath, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil {
|
||||
t.Fatalf("write dirty file: %v", err)
|
||||
}
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_worktree_002",
|
||||
"--goal", "Validate dirty repo rejection",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_worktree_002",
|
||||
"--task", "T1",
|
||||
"--title", "Implement backend",
|
||||
"--default-to", "worker-a",
|
||||
)
|
||||
|
||||
stdout, _, exitCode := executeOrchCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_worktree_002",
|
||||
"--task", "T1",
|
||||
"--repo-path", repoPath,
|
||||
"--workspace-root", ".orch/worktrees",
|
||||
"--strict-worktree",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_state")
|
||||
|
||||
if _, err := os.Stat(filepath.Join(repoPath, ".orch", "worktrees", "run_blog_worktree_002", "T1", "attempt-1")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no worktree directory on strict failure, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchStrictWorktreeAllowsExplicitBaseRefOnDirtyRepo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
repoPath := initGitRepo(t)
|
||||
baseCommit := gitHeadCommit(t, repoPath)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(repoPath, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil {
|
||||
t.Fatalf("write dirty file: %v", err)
|
||||
}
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_worktree_003",
|
||||
"--goal", "Validate explicit base ref on dirty repo",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_worktree_003",
|
||||
"--task", "T1",
|
||||
"--title", "Implement backend",
|
||||
"--default-to", "worker-a",
|
||||
)
|
||||
|
||||
dispatchOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_worktree_003",
|
||||
"--task", "T1",
|
||||
"--repo-path", repoPath,
|
||||
"--workspace-root", ".orch/worktrees",
|
||||
"--strict-worktree",
|
||||
"--base-ref", "HEAD",
|
||||
)
|
||||
|
||||
var dispatchResp map[string]any
|
||||
mustDecodeJSON(t, dispatchOut, &dispatchResp)
|
||||
if got := nestedString(t, dispatchResp, "data", "attempt", "base_ref"); got != "HEAD" {
|
||||
t.Fatalf("expected explicit base_ref HEAD, got %q", got)
|
||||
}
|
||||
if got := nestedString(t, dispatchResp, "data", "attempt", "base_commit"); got != baseCommit {
|
||||
t.Fatalf("expected base_commit %q, got %q", baseCommit, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newDispatchWorkspacePreparer(cmd *cobra.Command, opts dispatchOptions) store.DispatchWorkspacePreparer {
|
||||
if !dispatchUsesWorktree(opts) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
|
||||
return func(task store.Task, attemptNo int) (store.DispatchWorkspace, func(), error) {
|
||||
return provisionDispatchWorkspace(ctx, opts, task, attemptNo)
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchUsesWorktree(opts dispatchOptions) bool {
|
||||
return strings.TrimSpace(opts.repoPath) != "" ||
|
||||
strings.TrimSpace(opts.workspaceRoot) != "" ||
|
||||
opts.strictWorktree
|
||||
}
|
||||
|
||||
func provisionDispatchWorkspace(ctx context.Context, opts dispatchOptions, task store.Task, attemptNo int) (store.DispatchWorkspace, func(), error) {
|
||||
repoRoot, err := resolveRepoRoot(ctx, opts.repoPath)
|
||||
if err != nil {
|
||||
return store.DispatchWorkspace{}, nil, err
|
||||
}
|
||||
|
||||
workspaceRoot := resolveWorkspaceRoot(repoRoot, opts.workspaceRoot)
|
||||
if err := ensureWorkspaceRootIgnored(repoRoot, workspaceRoot); err != nil {
|
||||
return store.DispatchWorkspace{}, nil, err
|
||||
}
|
||||
|
||||
baseRef, baseCommit, err := resolveDispatchBase(ctx, repoRoot, workspaceRoot, opts.baseRef, opts.strictWorktree)
|
||||
if err != nil {
|
||||
return store.DispatchWorkspace{}, nil, err
|
||||
}
|
||||
|
||||
branchName := buildAttemptBranchName(task.RunID, task.TaskID, attemptNo)
|
||||
worktreePath := buildAttemptWorktreePath(workspaceRoot, task.RunID, task.TaskID, attemptNo)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
|
||||
return store.DispatchWorkspace{}, nil, fmt.Errorf("create worktree parent dir: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(worktreePath); err == nil {
|
||||
return store.DispatchWorkspace{}, nil, fmt.Errorf("%w: worktree path already exists: %s", store.ErrInvalidState, worktreePath)
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return store.DispatchWorkspace{}, nil, fmt.Errorf("stat worktree path: %w", err)
|
||||
}
|
||||
|
||||
if _, _, err := runGit(ctx, repoRoot, "worktree", "add", "-b", branchName, worktreePath, baseCommit); err != nil {
|
||||
return store.DispatchWorkspace{}, nil, err
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_, _, _ = runGit(context.Background(), repoRoot, "worktree", "remove", "--force", worktreePath)
|
||||
_, _, _ = runGit(context.Background(), repoRoot, "branch", "-D", branchName)
|
||||
_ = os.RemoveAll(worktreePath)
|
||||
}
|
||||
|
||||
return store.DispatchWorkspace{
|
||||
BaseRef: baseRef,
|
||||
BaseCommit: baseCommit,
|
||||
BranchName: branchName,
|
||||
WorktreePath: worktreePath,
|
||||
WorkspaceStatus: "created",
|
||||
}, cleanup, nil
|
||||
}
|
||||
|
||||
func resolveRepoRoot(ctx context.Context, repoPath string) (string, error) {
|
||||
startPath := strings.TrimSpace(repoPath)
|
||||
if startPath == "" {
|
||||
var err error
|
||||
startPath, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get current working directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(startPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve repo path: %w", err)
|
||||
}
|
||||
|
||||
stdout, _, err := runGit(ctx, absPath, "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
return "", protocol.InvalidInput("repo-path must point to a Git worktree", err)
|
||||
}
|
||||
return strings.TrimSpace(stdout), nil
|
||||
}
|
||||
|
||||
func resolveDispatchBase(ctx context.Context, repoRoot, workspaceRoot, requestedBaseRef string, strict bool) (string, string, error) {
|
||||
baseRef := strings.TrimSpace(requestedBaseRef)
|
||||
if baseRef != "" {
|
||||
baseCommit, err := resolveCommit(ctx, repoRoot, baseRef)
|
||||
if err != nil {
|
||||
return "", "", protocol.InvalidInput("base-ref must resolve to a commit", err)
|
||||
}
|
||||
return baseRef, baseCommit, nil
|
||||
}
|
||||
|
||||
if strict {
|
||||
dirty, err := repoHasUncommittedChanges(ctx, repoRoot, workspaceRoot)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if dirty {
|
||||
return "", "", fmt.Errorf("%w: repository has uncommitted changes; specify --base-ref or clean the repo", store.ErrInvalidState)
|
||||
}
|
||||
}
|
||||
|
||||
baseCommit, err := resolveCommit(ctx, repoRoot, "HEAD")
|
||||
if err != nil {
|
||||
return "", "", protocol.InvalidInput("failed to resolve HEAD commit", err)
|
||||
}
|
||||
return "HEAD", baseCommit, nil
|
||||
}
|
||||
|
||||
func resolveCommit(ctx context.Context, repoRoot, ref string) (string, error) {
|
||||
stdout, _, err := runGit(ctx, repoRoot, "rev-parse", "--verify", ref+"^{commit}")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(stdout), nil
|
||||
}
|
||||
|
||||
func repoHasUncommittedChanges(ctx context.Context, repoRoot, workspaceRoot string) (bool, error) {
|
||||
stdout, _, err := runGit(ctx, repoRoot, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check repository status: %w", err)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if len(line) >= 3 {
|
||||
path := strings.TrimSpace(line[3:])
|
||||
if shouldIgnoreStatusPath(repoRoot, workspaceRoot, path) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func resolveWorkspaceRoot(repoRoot, configuredRoot string) string {
|
||||
root := strings.TrimSpace(configuredRoot)
|
||||
if root == "" {
|
||||
return filepath.Join(repoRoot, ".orch", "worktrees")
|
||||
}
|
||||
if filepath.IsAbs(root) {
|
||||
return root
|
||||
}
|
||||
return filepath.Join(repoRoot, root)
|
||||
}
|
||||
|
||||
func ensureWorkspaceRootIgnored(repoRoot, workspaceRoot string) error {
|
||||
relative, err := filepath.Rel(repoRoot, workspaceRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve workspace root exclude path: %w", err)
|
||||
}
|
||||
if relative == "." || strings.HasPrefix(relative, "..") {
|
||||
return nil
|
||||
}
|
||||
|
||||
pattern := filepath.ToSlash(relative)
|
||||
if !strings.HasSuffix(pattern, "/") {
|
||||
pattern += "/"
|
||||
}
|
||||
|
||||
excludePath := filepath.Join(repoRoot, ".git", "info", "exclude")
|
||||
if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
|
||||
return fmt.Errorf("create git info dir: %w", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(excludePath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read git exclude file: %w", err)
|
||||
}
|
||||
if strings.Contains(string(content), pattern) {
|
||||
return nil
|
||||
}
|
||||
|
||||
appendContent := pattern + "\n"
|
||||
if len(content) > 0 && !strings.HasSuffix(string(content), "\n") {
|
||||
appendContent = "\n" + appendContent
|
||||
}
|
||||
file, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open git exclude file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := file.WriteString(appendContent); err != nil {
|
||||
return fmt.Errorf("append git exclude file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldIgnoreStatusPath(repoRoot, workspaceRoot, statusPath string) bool {
|
||||
relative, err := filepath.Rel(repoRoot, workspaceRoot)
|
||||
if err != nil || relative == "." || strings.HasPrefix(relative, "..") {
|
||||
return false
|
||||
}
|
||||
|
||||
relative = filepath.ToSlash(relative)
|
||||
statusPath = filepath.ToSlash(strings.Trim(statusPath, `"`))
|
||||
return statusPath == relative || strings.HasPrefix(statusPath, relative+"/")
|
||||
}
|
||||
|
||||
func buildAttemptBranchName(runID, taskID string, attemptNo int) string {
|
||||
return fmt.Sprintf(
|
||||
"orch/%s/%s/attempt-%d",
|
||||
sanitizeGitSegment(runID),
|
||||
sanitizeGitSegment(taskID),
|
||||
attemptNo,
|
||||
)
|
||||
}
|
||||
|
||||
func buildAttemptWorktreePath(workspaceRoot, runID, taskID string, attemptNo int) string {
|
||||
return filepath.Join(
|
||||
workspaceRoot,
|
||||
sanitizePathSegment(runID),
|
||||
sanitizePathSegment(taskID),
|
||||
fmt.Sprintf("attempt-%d", attemptNo),
|
||||
)
|
||||
}
|
||||
|
||||
func sanitizeGitSegment(value string) string {
|
||||
return sanitizeSegment(value)
|
||||
}
|
||||
|
||||
func sanitizePathSegment(value string) string {
|
||||
return sanitizeSegment(value)
|
||||
}
|
||||
|
||||
func sanitizeSegment(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "item"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if r == '-' || r == '_' || r == '.' {
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.Trim(b.String(), "-.")
|
||||
if result == "" {
|
||||
return "item"
|
||||
}
|
||||
if strings.HasSuffix(result, ".lock") {
|
||||
result = strings.TrimSuffix(result, ".lock") + "-lock"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func runGit(ctx context.Context, repoRoot string, args ...string) (string, string, error) {
|
||||
cmdArgs := append([]string{"-C", repoRoot}, args...)
|
||||
cmd := exec.CommandContext(ctx, "git", cmdArgs...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return string(output), "", nil
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return "", message, fmt.Errorf("git %s: %s", strings.Join(args, " "), message)
|
||||
}
|
||||
+95
-31
@@ -98,11 +98,12 @@ type ListReadyInput struct {
|
||||
}
|
||||
|
||||
type DispatchInput struct {
|
||||
RunID string
|
||||
TaskID string
|
||||
ToAgent string
|
||||
Body string
|
||||
BaseRef string
|
||||
RunID string
|
||||
TaskID string
|
||||
ToAgent string
|
||||
Body string
|
||||
BaseRef string
|
||||
PrepareWorkspace DispatchWorkspacePreparer
|
||||
}
|
||||
|
||||
type DispatchResult struct {
|
||||
@@ -118,6 +119,16 @@ type ReconcileResult struct {
|
||||
UpdatedTasks []Task `json:"updated_tasks"`
|
||||
}
|
||||
|
||||
type DispatchWorkspace struct {
|
||||
BaseRef string `json:"base_ref,omitempty"`
|
||||
BaseCommit string `json:"base_commit,omitempty"`
|
||||
BranchName string `json:"branch_name,omitempty"`
|
||||
WorktreePath string `json:"worktree_path,omitempty"`
|
||||
WorkspaceStatus string `json:"workspace_status,omitempty"`
|
||||
}
|
||||
|
||||
type DispatchWorkspacePreparer func(task Task, attemptNo int) (DispatchWorkspace, func(), error)
|
||||
|
||||
type BlockedTask struct {
|
||||
Task Task `json:"task"`
|
||||
Attempt TaskAttempt `json:"attempt"`
|
||||
@@ -473,9 +484,29 @@ func (s *OrchStore) DispatchTask(ctx context.Context, input DispatchInput) (Disp
|
||||
}
|
||||
|
||||
attemptNo := task.LatestAttemptNo + 1
|
||||
workspace := DispatchWorkspace{
|
||||
BaseRef: strings.TrimSpace(input.BaseRef),
|
||||
}
|
||||
cleanupWorkspace := func() {}
|
||||
workspaceCommitted := false
|
||||
if input.PrepareWorkspace != nil {
|
||||
workspace, cleanupWorkspace, err = input.PrepareWorkspace(task, attemptNo)
|
||||
if err != nil {
|
||||
return DispatchResult{}, err
|
||||
}
|
||||
if cleanupWorkspace == nil {
|
||||
cleanupWorkspace = func() {}
|
||||
}
|
||||
defer func() {
|
||||
if !workspaceCommitted {
|
||||
cleanupWorkspace()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
threadID := newID("thr")
|
||||
messageID := newID("msg")
|
||||
payloadJSON := buildDispatchPayload(task, attemptNo, input.BaseRef)
|
||||
payloadJSON := buildDispatchPayload(task, attemptNo, workspace)
|
||||
thread := Thread{
|
||||
ThreadID: threadID,
|
||||
RunID: task.RunID,
|
||||
@@ -541,15 +572,19 @@ func (s *OrchStore) DispatchTask(ctx context.Context, input DispatchInput) (Disp
|
||||
}
|
||||
|
||||
attempt := TaskAttempt{
|
||||
RunID: task.RunID,
|
||||
TaskID: task.TaskID,
|
||||
AttemptNo: attemptNo,
|
||||
AssignedTo: assignedTo,
|
||||
ThreadID: threadID,
|
||||
BaseRef: strings.TrimSpace(input.BaseRef),
|
||||
Status: "dispatched",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
RunID: task.RunID,
|
||||
TaskID: task.TaskID,
|
||||
AttemptNo: attemptNo,
|
||||
AssignedTo: assignedTo,
|
||||
ThreadID: threadID,
|
||||
BaseRef: workspace.BaseRef,
|
||||
BaseCommit: workspace.BaseCommit,
|
||||
BranchName: workspace.BranchName,
|
||||
WorktreePath: workspace.WorktreePath,
|
||||
WorkspaceStatus: workspace.WorkspaceStatus,
|
||||
Status: "dispatched",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
@@ -564,10 +599,10 @@ func (s *OrchStore) DispatchTask(ctx context.Context, input DispatchInput) (Disp
|
||||
attempt.AssignedTo,
|
||||
attempt.ThreadID,
|
||||
nullIfEmpty(attempt.BaseRef),
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nullIfEmpty(attempt.BaseCommit),
|
||||
nullIfEmpty(attempt.BranchName),
|
||||
nullIfEmpty(attempt.WorktreePath),
|
||||
nullIfEmpty(attempt.WorkspaceStatus),
|
||||
nil,
|
||||
attempt.Status,
|
||||
formatTime(attempt.CreatedAt),
|
||||
@@ -613,6 +648,7 @@ func (s *OrchStore) DispatchTask(ctx context.Context, input DispatchInput) (Disp
|
||||
if err := tx.Commit(); err != nil {
|
||||
return DispatchResult{}, fmt.Errorf("commit dispatch transaction: %w", err)
|
||||
}
|
||||
workspaceCommitted = true
|
||||
|
||||
task.Status = "dispatched"
|
||||
task.LatestAttemptNo = attempt.AttemptNo
|
||||
@@ -704,9 +740,10 @@ func (s *OrchStore) ReconcileRun(ctx context.Context, runID string) (ReconcileRe
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE task_attempts
|
||||
SET status = ?, updated_at = ?
|
||||
SET status = ?, workspace_status = COALESCE(?, workspace_status), updated_at = ?
|
||||
WHERE run_id = ? AND task_id = ? AND attempt_no = ?`,
|
||||
nextStatus,
|
||||
nullIfEmpty(reconcileWorkspaceStatus(threadStatus)),
|
||||
formatTime(now),
|
||||
runID,
|
||||
taskID,
|
||||
@@ -1116,14 +1153,14 @@ func scanTask(scanner threadScanner) (Task, error) {
|
||||
|
||||
func scanAttempt(scanner threadScanner) (TaskAttempt, error) {
|
||||
var (
|
||||
attempt TaskAttempt
|
||||
baseRef sql.NullString
|
||||
baseCommit sql.NullString
|
||||
branchName sql.NullString
|
||||
worktreePath sql.NullString
|
||||
workspaceStatus sql.NullString
|
||||
resultCommit sql.NullString
|
||||
createdAt, updated string
|
||||
attempt TaskAttempt
|
||||
baseRef sql.NullString
|
||||
baseCommit sql.NullString
|
||||
branchName sql.NullString
|
||||
worktreePath sql.NullString
|
||||
workspaceStatus sql.NullString
|
||||
resultCommit sql.NullString
|
||||
createdAt, updated string
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
@@ -1580,7 +1617,7 @@ func validateAndNormalizeJSONDefault(fieldName, value, defaultValue string) (str
|
||||
return compact.String(), nil
|
||||
}
|
||||
|
||||
func buildDispatchPayload(task Task, attemptNo int, baseRef string) string {
|
||||
func buildDispatchPayload(task Task, attemptNo int, workspace DispatchWorkspace) string {
|
||||
payload := map[string]any{
|
||||
"run_id": task.RunID,
|
||||
"task_id": task.TaskID,
|
||||
@@ -1596,8 +1633,20 @@ func buildDispatchPayload(task Task, attemptNo int, baseRef string) string {
|
||||
payload["acceptance"] = acceptance
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(baseRef) != "" {
|
||||
payload["base_ref"] = strings.TrimSpace(baseRef)
|
||||
if strings.TrimSpace(workspace.BaseRef) != "" {
|
||||
payload["base_ref"] = strings.TrimSpace(workspace.BaseRef)
|
||||
}
|
||||
if strings.TrimSpace(workspace.BaseCommit) != "" {
|
||||
payload["base_commit"] = strings.TrimSpace(workspace.BaseCommit)
|
||||
}
|
||||
if strings.TrimSpace(workspace.BranchName) != "" {
|
||||
payload["branch_name"] = strings.TrimSpace(workspace.BranchName)
|
||||
}
|
||||
if strings.TrimSpace(workspace.WorktreePath) != "" {
|
||||
payload["worktree_path"] = strings.TrimSpace(workspace.WorktreePath)
|
||||
}
|
||||
if strings.TrimSpace(workspace.WorkspaceStatus) != "" {
|
||||
payload["workspace_status"] = strings.TrimSpace(workspace.WorkspaceStatus)
|
||||
}
|
||||
|
||||
return marshalJSON(payload)
|
||||
@@ -1634,6 +1683,21 @@ func summarizeAnswer(body string) string {
|
||||
return line
|
||||
}
|
||||
|
||||
func reconcileWorkspaceStatus(threadStatus string) string {
|
||||
switch threadStatus {
|
||||
case "pending":
|
||||
return "created"
|
||||
case "claimed", "in_progress", "blocked":
|
||||
return "active"
|
||||
case "done", "failed":
|
||||
return "completed"
|
||||
case "cancelled":
|
||||
return "abandoned"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user