Auto-enable worktrees for code tasks
This commit is contained in:
@@ -728,6 +728,102 @@ func TestOrchStrictWorktreeAllowsExplicitBaseRefOnDirtyRepo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchDispatchAutoEnablesWorktreeForCodeLikeTask(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
repoPath := initGitRepo(t)
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_auto_worktree_001",
|
||||
"--goal", "Validate auto worktree detection",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_auto_worktree_001",
|
||||
"--task", "T1",
|
||||
"--title", "Implement backend API",
|
||||
"--default-to", "backend-worker",
|
||||
)
|
||||
|
||||
dispatchOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_auto_worktree_001",
|
||||
"--task", "T1",
|
||||
"--repo-path", repoPath,
|
||||
)
|
||||
|
||||
var dispatchResp map[string]any
|
||||
mustDecodeJSON(t, dispatchOut, &dispatchResp)
|
||||
attempt := nestedValue(t, dispatchResp, "data", "attempt").(map[string]any)
|
||||
|
||||
worktreePath, _ := attempt["worktree_path"].(string)
|
||||
if worktreePath == "" {
|
||||
t.Fatalf("expected auto-detected code task to allocate a worktree, got %#v", attempt)
|
||||
}
|
||||
if got, _ := attempt["workspace_status"].(string); got != "created" {
|
||||
t.Fatalf("expected created workspace status, got %#v", attempt["workspace_status"])
|
||||
}
|
||||
if _, err := os.Stat(worktreePath); err != nil {
|
||||
t.Fatalf("stat auto worktree path %s: %v", worktreePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchDispatchDoesNotAutoEnableWorktreeForNonCodeTask(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_auto_worktree_002",
|
||||
"--goal", "Validate non-code dispatch fallback",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_auto_worktree_002",
|
||||
"--task", "T1",
|
||||
"--title", "Review QA findings",
|
||||
"--summary", "Summarize test failures and next steps",
|
||||
"--default-to", "qa-worker",
|
||||
)
|
||||
|
||||
dispatchOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_auto_worktree_002",
|
||||
"--task", "T1",
|
||||
"--repo-path", repoPath,
|
||||
)
|
||||
|
||||
var dispatchResp map[string]any
|
||||
mustDecodeJSON(t, dispatchOut, &dispatchResp)
|
||||
attempt := nestedValue(t, dispatchResp, "data", "attempt").(map[string]any)
|
||||
if got, _ := attempt["worktree_path"].(string); got != "" {
|
||||
t.Fatalf("expected non-code task to stay on non-worktree path, got %#v", attempt["worktree_path"])
|
||||
}
|
||||
if got, _ := attempt["workspace_status"].(string); got != "" {
|
||||
t.Fatalf("expected no workspace status for non-code task, got %#v", attempt["workspace_status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchWaitWakesOnBlockedEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package orch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -15,14 +16,14 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
effectiveOpts, useWorktree := resolveDispatchWorktreeOptions(task, opts)
|
||||
if !useWorktree {
|
||||
return store.DispatchWorkspace{}, func() {}, nil
|
||||
}
|
||||
return provisionDispatchWorkspace(ctx, effectiveOpts, task, attemptNo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +53,104 @@ func newAttemptReuseWorkspacePreparer(cmd *cobra.Command, task store.Task, attem
|
||||
}
|
||||
|
||||
func dispatchUsesWorktree(opts dispatchOptions) bool {
|
||||
return strings.TrimSpace(opts.repoPath) != "" ||
|
||||
strings.TrimSpace(opts.workspaceRoot) != "" ||
|
||||
return strings.TrimSpace(opts.workspaceRoot) != "" ||
|
||||
opts.strictWorktree
|
||||
}
|
||||
|
||||
func resolveDispatchWorktreeOptions(task store.Task, opts dispatchOptions) (dispatchOptions, bool) {
|
||||
if dispatchUsesWorktree(opts) {
|
||||
return opts, true
|
||||
}
|
||||
if !taskLooksLikeCodeWork(task) {
|
||||
return opts, false
|
||||
}
|
||||
|
||||
auto := opts
|
||||
auto.strictWorktree = true
|
||||
return auto, true
|
||||
}
|
||||
|
||||
func taskLooksLikeCodeWork(task store.Task) bool {
|
||||
if acceptanceJSONLooksCodeLike(task.AcceptanceJSON) {
|
||||
return true
|
||||
}
|
||||
return roleLooksCodeLike(task.DefaultTo)
|
||||
}
|
||||
|
||||
func acceptanceJSONLooksCodeLike(raw json.RawMessage) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return false
|
||||
}
|
||||
return acceptanceValueLooksCodeLike(value)
|
||||
}
|
||||
|
||||
func acceptanceValueLooksCodeLike(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, raw := range typed {
|
||||
lowerKey := strings.ToLower(strings.TrimSpace(key))
|
||||
switch lowerKey {
|
||||
case "code", "code_task", "writes_code", "worktree":
|
||||
if boolValue, ok := raw.(bool); ok && boolValue {
|
||||
return true
|
||||
}
|
||||
case "kind", "task_type", "mode", "type":
|
||||
if stringValue, ok := raw.(string); ok && isCodeLikeMarker(stringValue) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if acceptanceValueLooksCodeLike(raw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if acceptanceValueLooksCodeLike(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case string:
|
||||
return isCodeLikeMarker(typed)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func roleLooksCodeLike(role string) bool {
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
if role == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, token := range splitIdentifierTokens(role) {
|
||||
switch token {
|
||||
case "backend", "frontend", "front", "admin", "ui", "fullstack", "foundation", "db", "database", "mobile", "ios", "android", "web", "platform", "infra", "api":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isCodeLikeMarker(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "code", "code_task", "code-task", "code-change", "code_change", "implementation", "patch", "diff", "repo":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func splitIdentifierTokens(value string) []string {
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'))
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user