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 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:
+3
View File
@@ -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
+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:
- 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
+8 -9
View File
@@ -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)
}
}
+222
View File
@@ -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)
}
}
+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)
}
+95 -31
View File
@@ -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")
}