Add orch strict worktree dispatch

This commit is contained in:
2026-03-19 13:52:51 +08:00
parent 07f4a6fdae
commit 1b0cd723d7
9 changed files with 776 additions and 48 deletions
+28 -8
View File
@@ -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 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` now implements `run init/show`, `task add`, `dep add`, `ready`, `dispatch`, `reconcile`, `blocked`, `answer`, and `status` - `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 - `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
- automated integration tests now cover the main `orch` scheduler slice, including dependency gating, dispatch, blocked-answer flow, and reconcile - `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 ## Source Of Truth
@@ -69,9 +70,10 @@ Current implementation status:
- `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 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` - `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 ### Milestone 1: Go Skeleton
@@ -239,7 +241,6 @@ Completed so far:
Remaining: Remaining:
- strict worktree provisioning on dispatch
- `orch wait` - `orch wait`
- retry, reassign, cancel, and cleanup workflows - retry, reassign, cancel, and cleanup workflows
@@ -260,6 +261,24 @@ Definition of done:
- a code task dispatch creates a real worktree - a code task dispatch creates a real worktree
- the assigned worktree path appears in attempt metadata and inbox payload - 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 ### Milestone 6: Waiting Primitives
Goal: Goal:
@@ -299,11 +318,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 5: Strict Worktree Support` 1. start `Milestone 6: Waiting Primitives`
2. add real worktree metadata population to `orch dispatch` 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 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 ## Recommended Driver Choices
@@ -326,6 +345,7 @@ Completed so far:
- inbox workflow lifecycle coverage - inbox workflow lifecycle coverage
- orch scheduler lifecycle coverage for run/task/dependency/dispatch/reconcile - orch scheduler lifecycle coverage for run/task/dependency/dispatch/reconcile
- orch blocked-question and answer coverage - orch blocked-question and answer coverage
- orch strict worktree creation and dirty-repo policy coverage
Still recommended before the codebase grows too much: Still recommended before the codebase grows too much:
+3
View File
@@ -177,6 +177,7 @@ Suggested flags:
- `--run RUN_ID` - `--run RUN_ID`
- `--task TASK_ID` - `--task TASK_ID`
- `--to AGENT` - `--to AGENT`
- `--repo-path PATH`
- `--base-ref REF` - `--base-ref REF`
- `--workspace-root PATH` - `--workspace-root PATH`
- `--strict-worktree` - `--strict-worktree`
@@ -186,6 +187,7 @@ Suggested flags:
Behavior: Behavior:
- creates a new attempt - creates a new attempt
- resolves the source repository from `--repo-path` or the current working directory
- resolves a committed base revision - resolves a committed base revision
- creates a branch and worktree for the attempt when the task writes code - creates a branch and worktree for the attempt when the task writes code
- creates or links an `inbox` thread - 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 clean, default to `HEAD`
- if `--base-ref` is omitted and the repository is dirty, fail dispatch - 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 `--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` ### `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
+1
View File
@@ -60,6 +60,7 @@ This keeps worker execution reproducible and avoids hidden divergence from the l
For one task attempt, `orch` should: For one task attempt, `orch` should:
- resolve the source repository from the caller context or an explicit repo-path override
- pick a `base_ref` - pick a `base_ref`
- create an attempt record - create an attempt record
- choose a branch name - choose a branch name
+3 -4
View File
@@ -16,6 +16,7 @@ type dispatchOptions struct {
body string body string
bodyFile string bodyFile string
baseRef string baseRef string
repoPath string
workspaceRoot string workspaceRoot string
strictWorktree bool strictWorktree bool
} }
@@ -27,10 +28,6 @@ func newDispatchCmd(root *rootOptions) *cobra.Command {
Use: "dispatch", Use: "dispatch",
Short: "Dispatch a ready task to a worker through inbox", Short: "Dispatch a ready task to a worker through inbox",
RunE: func(cmd *cobra.Command, args []string) error { 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) body, err := resolveBodyValue(opts.body, opts.bodyFile)
if err != nil { if err != nil {
return err return err
@@ -49,6 +46,7 @@ func newDispatchCmd(root *rootOptions) *cobra.Command {
ToAgent: opts.toAgent, ToAgent: opts.toAgent,
Body: body, Body: body,
BaseRef: opts.baseRef, BaseRef: opts.baseRef,
PrepareWorkspace: newDispatchWorkspacePreparer(cmd, *opts),
}) })
if err != nil { if err != nil {
return err 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.body, "body", "", "Task message body")
cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read task message body from file") 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.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().StringVar(&opts.workspaceRoot, "workspace-root", "", "Workspace root for worktree dispatch")
cmd.Flags().BoolVar(&opts.strictWorktree, "strict-worktree", false, "Require strict worktree setup") cmd.Flags().BoolVar(&opts.strictWorktree, "strict-worktree", false, "Require strict worktree setup")
_ = cmd.MarkFlagRequired("run") _ = 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)
}
}
+222
View File
@@ -1,6 +1,7 @@
package orch package orch
import ( import (
"os"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@@ -504,3 +505,224 @@ func TestOrchDispatchRejectsNonReadyTask(t *testing.T) {
} }
assertErrorJSON(t, stdout, "invalid_state") 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)
}
}
+303
View File
@@ -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)
}
+74 -10
View File
@@ -103,6 +103,7 @@ type DispatchInput struct {
ToAgent string ToAgent string
Body string Body string
BaseRef string BaseRef string
PrepareWorkspace DispatchWorkspacePreparer
} }
type DispatchResult struct { type DispatchResult struct {
@@ -118,6 +119,16 @@ type ReconcileResult struct {
UpdatedTasks []Task `json:"updated_tasks"` 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 { type BlockedTask struct {
Task Task `json:"task"` Task Task `json:"task"`
Attempt TaskAttempt `json:"attempt"` Attempt TaskAttempt `json:"attempt"`
@@ -473,9 +484,29 @@ func (s *OrchStore) DispatchTask(ctx context.Context, input DispatchInput) (Disp
} }
attemptNo := task.LatestAttemptNo + 1 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") threadID := newID("thr")
messageID := newID("msg") messageID := newID("msg")
payloadJSON := buildDispatchPayload(task, attemptNo, input.BaseRef) payloadJSON := buildDispatchPayload(task, attemptNo, workspace)
thread := Thread{ thread := Thread{
ThreadID: threadID, ThreadID: threadID,
RunID: task.RunID, RunID: task.RunID,
@@ -546,7 +577,11 @@ func (s *OrchStore) DispatchTask(ctx context.Context, input DispatchInput) (Disp
AttemptNo: attemptNo, AttemptNo: attemptNo,
AssignedTo: assignedTo, AssignedTo: assignedTo,
ThreadID: threadID, ThreadID: threadID,
BaseRef: strings.TrimSpace(input.BaseRef), BaseRef: workspace.BaseRef,
BaseCommit: workspace.BaseCommit,
BranchName: workspace.BranchName,
WorktreePath: workspace.WorktreePath,
WorkspaceStatus: workspace.WorkspaceStatus,
Status: "dispatched", Status: "dispatched",
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
@@ -564,10 +599,10 @@ func (s *OrchStore) DispatchTask(ctx context.Context, input DispatchInput) (Disp
attempt.AssignedTo, attempt.AssignedTo,
attempt.ThreadID, attempt.ThreadID,
nullIfEmpty(attempt.BaseRef), nullIfEmpty(attempt.BaseRef),
nil, nullIfEmpty(attempt.BaseCommit),
nil, nullIfEmpty(attempt.BranchName),
nil, nullIfEmpty(attempt.WorktreePath),
nil, nullIfEmpty(attempt.WorkspaceStatus),
nil, nil,
attempt.Status, attempt.Status,
formatTime(attempt.CreatedAt), formatTime(attempt.CreatedAt),
@@ -613,6 +648,7 @@ func (s *OrchStore) DispatchTask(ctx context.Context, input DispatchInput) (Disp
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return DispatchResult{}, fmt.Errorf("commit dispatch transaction: %w", err) return DispatchResult{}, fmt.Errorf("commit dispatch transaction: %w", err)
} }
workspaceCommitted = true
task.Status = "dispatched" task.Status = "dispatched"
task.LatestAttemptNo = attempt.AttemptNo task.LatestAttemptNo = attempt.AttemptNo
@@ -704,9 +740,10 @@ func (s *OrchStore) ReconcileRun(ctx context.Context, runID string) (ReconcileRe
_, err = tx.ExecContext( _, err = tx.ExecContext(
ctx, ctx,
`UPDATE task_attempts `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 = ?`, WHERE run_id = ? AND task_id = ? AND attempt_no = ?`,
nextStatus, nextStatus,
nullIfEmpty(reconcileWorkspaceStatus(threadStatus)),
formatTime(now), formatTime(now),
runID, runID,
taskID, taskID,
@@ -1580,7 +1617,7 @@ func validateAndNormalizeJSONDefault(fieldName, value, defaultValue string) (str
return compact.String(), nil 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{ payload := map[string]any{
"run_id": task.RunID, "run_id": task.RunID,
"task_id": task.TaskID, "task_id": task.TaskID,
@@ -1596,8 +1633,20 @@ func buildDispatchPayload(task Task, attemptNo int, baseRef string) string {
payload["acceptance"] = acceptance payload["acceptance"] = acceptance
} }
} }
if strings.TrimSpace(baseRef) != "" { if strings.TrimSpace(workspace.BaseRef) != "" {
payload["base_ref"] = strings.TrimSpace(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) return marshalJSON(payload)
@@ -1634,6 +1683,21 @@ func summarizeAnswer(body string) string {
return line 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 { func isUniqueConstraintError(err error) bool {
return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed") return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
} }