relay-flow 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ // Package orcacli wraps the `orca` CLI for worktree/terminal management.
2
+ // Every call is skippable via DryRun for safe local testing. All real
3
+ // `orca ... --json` output is wrapped as {id, ok, result: {...}}.
4
+ package orcacli
5
+
6
+ import (
7
+ "encoding/json"
8
+ "fmt"
9
+ "log"
10
+ "os/exec"
11
+ "regexp"
12
+ "strings"
13
+ )
14
+
15
+ type Client struct {
16
+ DryRun bool
17
+ }
18
+
19
+ func New(dryRun bool) *Client {
20
+ return &Client{DryRun: dryRun}
21
+ }
22
+
23
+ type Repo struct {
24
+ ID string `json:"id"`
25
+ DisplayName string `json:"displayName"`
26
+ }
27
+
28
+ type Worktree struct {
29
+ ID string `json:"id"`
30
+ RepoID string `json:"repoId"`
31
+ DisplayName string `json:"displayName"`
32
+ Branch string `json:"branch"`
33
+ Path string `json:"path"`
34
+ IsMainWorktree bool `json:"isMainWorktree"`
35
+ }
36
+
37
+ // Terminal is a tab's persistent identity: Title is the tab-level title we
38
+ // set via --title (visualLayouts[].root.tabs[].title), which persists —
39
+ // unlike the pane-level title, which the running program (opencode) resets.
40
+ type Terminal struct {
41
+ Handle string
42
+ Title string
43
+ Connected bool
44
+ }
45
+
46
+ // ListRepos returns all Orca-registered repos, used to resolve a Jira
47
+ // ticket's component name to a repoId (component name == repo displayName).
48
+ func (c *Client) ListRepos() ([]Repo, error) {
49
+ if c.DryRun {
50
+ log.Printf("[dry-run] orca repo list --json (skipped, returning empty)")
51
+ return nil, nil
52
+ }
53
+ var res struct {
54
+ Result struct {
55
+ Repos []Repo `json:"repos"`
56
+ } `json:"result"`
57
+ }
58
+ if err := runOrcaJSON(&res, "repo", "list", "--json"); err != nil {
59
+ return nil, fmt.Errorf("orca repo list: %w", err)
60
+ }
61
+ return res.Result.Repos, nil
62
+ }
63
+
64
+ func (c *Client) WorktreeList() ([]Worktree, error) {
65
+ if c.DryRun {
66
+ log.Printf("[dry-run] orca worktree list --json (skipped, returning empty)")
67
+ return nil, nil
68
+ }
69
+ var res struct {
70
+ Result struct {
71
+ Worktrees []Worktree `json:"worktrees"`
72
+ } `json:"result"`
73
+ }
74
+ if err := runOrcaJSON(&res, "worktree", "list", "--json"); err != nil {
75
+ return nil, fmt.Errorf("orca worktree list: %w", err)
76
+ }
77
+ return res.Result.Worktrees, nil
78
+ }
79
+
80
+ // WorktreeCreate always explicitly sets --parent-worktree and --base-branch
81
+ // (never relies on Orca's inferred defaults) so every ticket's worktree has
82
+ // a deliberate, known ancestry: main by default, or an explicit parent
83
+ // ticket's worktree/branch for subtasks.
84
+ func (c *Client) WorktreeCreate(ticketKey, repoID, parentWorktreeID, baseBranch string) error {
85
+ if c.DryRun {
86
+ log.Printf("[dry-run] orca worktree create --name %s --repo id:%s --parent-worktree worktree:%s --base-branch %s --json (skipped)", ticketKey, repoID, parentWorktreeID, baseBranch)
87
+ return nil
88
+ }
89
+ return runOrca("worktree", "create", "--name", ticketKey, "--repo", "id:"+repoID,
90
+ "--parent-worktree", "worktree:"+parentWorktreeID, "--base-branch", baseBranch, "--json")
91
+ }
92
+
93
+ // FindWorktree returns the worktree in repoID with the given displayName.
94
+ func (c *Client) FindWorktree(repoID, displayName string) (Worktree, bool, error) {
95
+ wts, err := c.WorktreeList()
96
+ if err != nil {
97
+ return Worktree{}, false, err
98
+ }
99
+ for _, w := range wts {
100
+ if w.RepoID == repoID && w.DisplayName == displayName {
101
+ return w, true, nil
102
+ }
103
+ }
104
+ return Worktree{}, false, nil
105
+ }
106
+
107
+ // FindExistingBranch looks for a branch containing ticketKey
108
+ // (prefix-agnostic — e.g. "Raj-Popat/KCC-1374" or "someone-else/KCC-1374"
109
+ // both match) in the repo checked out at repoPath, checking BOTH local and
110
+ // remote-tracking branches: Orca's worktree-create name-collision logic
111
+ // consults remote branches too, so a leftover origin/Raj-Popat/KCC-1377
112
+ // (pushed by an agent, local copy long deleted) would otherwise make Orca
113
+ // silently suffix the new worktree (-2, -3, ...) and desync every
114
+ // ticket-key-based lookup we do afterward.
115
+ //
116
+ // Return value is the ref to pass as --base-branch:
117
+ // - local-only branch -> its name as-is ("Raj-Popat/KCC-1377")
118
+ // - remote-only branch -> the remote-qualified ref ("origin/Raj-Popat/KCC-1377");
119
+ // Orca recognizes this as "create the worktree on this existing branch"
120
+ // and does NOT suffix the worktree name (verified empirically).
121
+ func FindExistingBranch(repoPath, ticketKey string) (string, bool, error) {
122
+ out, err := exec.Command("git", "-C", repoPath, "branch", "-a", "--list", "--format=%(refname:short)").CombinedOutput()
123
+ if err != nil {
124
+ return "", false, fmt.Errorf("git branch --list: %w: %s", err, out)
125
+ }
126
+ re := regexp.MustCompile(regexp.QuoteMeta(ticketKey))
127
+ for _, line := range strings.Split(string(out), "\n") {
128
+ branch := strings.TrimSpace(line)
129
+ if branch != "" && re.MatchString(branch) {
130
+ return branch, true, nil
131
+ }
132
+ }
133
+ return "", false, nil
134
+ }
135
+
136
+ // MainWorktree returns repoID's main worktree (the one checked out on the
137
+ // repo's primary branch, e.g. main).
138
+ func (c *Client) MainWorktree(repoID string) (Worktree, bool, error) {
139
+ wts, err := c.WorktreeList()
140
+ if err != nil {
141
+ return Worktree{}, false, err
142
+ }
143
+ for _, w := range wts {
144
+ if w.RepoID == repoID && w.IsMainWorktree {
145
+ return w, true, nil
146
+ }
147
+ }
148
+ return Worktree{}, false, nil
149
+ }
150
+
151
+ // TerminalList returns tabs (with their persistent tab-level title) for a
152
+ // given worktree, e.g. "name:KCC-1373". --include-visual-layouts is
153
+ // mandatory: orca omits visualLayouts from JSON without it, which would
154
+ // make every lookup return zero tabs and the daemon would spawn duplicate
155
+ // terminals on every poll.
156
+ func (c *Client) TerminalList(worktree string) ([]Terminal, error) {
157
+ if c.DryRun {
158
+ log.Printf("[dry-run] orca terminal list --worktree %s --json (skipped, returning empty)", worktree)
159
+ return nil, nil
160
+ }
161
+ var res struct {
162
+ Result struct {
163
+ VisualLayouts []struct {
164
+ Root struct {
165
+ Tabs []struct {
166
+ Title string `json:"title"`
167
+ Panes struct {
168
+ Handle string `json:"handle"`
169
+ Connected bool `json:"connected"`
170
+ } `json:"panes"`
171
+ } `json:"tabs"`
172
+ } `json:"root"`
173
+ } `json:"visualLayouts"`
174
+ } `json:"result"`
175
+ }
176
+ if err := runOrcaJSON(&res, "terminal", "list", "--worktree", worktree, "--include-visual-layouts", "--json"); err != nil {
177
+ return nil, fmt.Errorf("orca terminal list: %w", err)
178
+ }
179
+ var terms []Terminal
180
+ for _, vl := range res.Result.VisualLayouts {
181
+ for _, tab := range vl.Root.Tabs {
182
+ terms = append(terms, Terminal{Handle: tab.Panes.Handle, Title: tab.Title, Connected: tab.Panes.Connected})
183
+ }
184
+ }
185
+ return terms, nil
186
+ }
187
+
188
+ // TerminalCreate launches the given shell command in a fresh terminal on
189
+ // the ticket's worktree. (orca terminal create has no --agent/--prompt
190
+ // flags — those exist only on `worktree create` — so the opencode
191
+ // invocation, with its RELAY_* env markers, is a --command line.)
192
+ func (c *Client) TerminalCreate(ticketKey, title, command string) (string, error) {
193
+ if c.DryRun {
194
+ log.Printf("[dry-run] orca terminal create --worktree name:%s --title %q --command %q --json (skipped)", ticketKey, title, command)
195
+ return "dry-run-handle", nil
196
+ }
197
+ var res struct {
198
+ Result struct {
199
+ Terminal struct {
200
+ Handle string `json:"handle"`
201
+ } `json:"terminal"`
202
+ } `json:"result"`
203
+ }
204
+ if err := runOrcaJSON(&res, "terminal", "create",
205
+ "--worktree", "name:"+ticketKey,
206
+ "--title", title,
207
+ "--command", command,
208
+ "--json"); err != nil {
209
+ return "", fmt.Errorf("orca terminal create: %w", err)
210
+ }
211
+ return res.Result.Terminal.Handle, nil
212
+ }
213
+
214
+ func (c *Client) TerminalWait(handle, forState string, timeoutMs int) error {
215
+ if c.DryRun {
216
+ log.Printf("[dry-run] orca terminal wait --terminal %s --for %s --timeout-ms %d --json (skipped)", handle, forState, timeoutMs)
217
+ return nil
218
+ }
219
+ return runOrca("terminal", "wait",
220
+ "--terminal", handle,
221
+ "--for", forState,
222
+ "--timeout-ms", fmt.Sprintf("%d", timeoutMs),
223
+ "--json")
224
+ }
225
+
226
+ func (c *Client) TerminalClose(handle string) error {
227
+ if c.DryRun {
228
+ log.Printf("[dry-run] orca terminal close --terminal %s (skipped)", handle)
229
+ return nil
230
+ }
231
+ return runOrca("terminal", "close", "--terminal", handle)
232
+ }
233
+
234
+ // TerminalSend types text into an existing terminal and presses Enter.
235
+ // text must be pre-flattened to a single line by the caller — send works
236
+ // via keystroke simulation, so an embedded newline would submit early.
237
+ func (c *Client) TerminalSend(handle, text string) error {
238
+ if c.DryRun {
239
+ log.Printf("[dry-run] orca terminal send --terminal %s --text %q --enter --json (skipped)", handle, text)
240
+ return nil
241
+ }
242
+ return runOrca("terminal", "send", "--terminal", handle, "--text", text, "--enter", "--json")
243
+ }
244
+
245
+ func runOrca(args ...string) error {
246
+ out, err := exec.Command("orca", args...).CombinedOutput()
247
+ if err != nil {
248
+ return fmt.Errorf("orca %v: %w: %s", args, err, string(out))
249
+ }
250
+ return nil
251
+ }
252
+
253
+ func runOrcaJSON(dest any, args ...string) error {
254
+ out, err := exec.Command("orca", args...).Output()
255
+ if err != nil {
256
+ // orca prints its JSON error envelope to stdout even on failure —
257
+ // surface it so callers can match on codes like selector_not_found.
258
+ return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
259
+ }
260
+ if err := json.Unmarshal(out, dest); err != nil {
261
+ return fmt.Errorf("parse json: %w", err)
262
+ }
263
+ return nil
264
+ }
@@ -0,0 +1,64 @@
1
+ # runner/orca — Orca adapter
2
+
3
+ Implements `runner.Runner` over the Orca CLI: each ticket gets an Orca
4
+ worktree; each node visit gets a fresh terminal titled
5
+ `<key>:<agent>:<node>` running opencode.
6
+
7
+ ## Config (runner.config)
8
+
9
+ Empty — the adapter takes no YAML fields. Repo binding arrives at runtime:
10
+ the server resolves the submitter's repo and calls `WithRepo(repoID,
11
+ displayName, dryRun)` after construction.
12
+
13
+ ## Behavior
14
+
15
+ - **Spawn** — verifies the opencode agent exists (`opencode agent list`),
16
+ ensures the ticket's worktree (creates off the repo's main worktree branch;
17
+ reuses an existing ticket branch if one exists; verifies the exact name
18
+ landed because Orca silently auto-suffixes on collisions), then creates the
19
+ terminal running `opencode --agent <agent> --prompt <p>` with `RELAY_FLOW_*` env
20
+ markers (`RELAY_FLOW_WORKFLOW/TICKET/NODE/AGENT`) so the plugin can report back.
21
+ - **Find** — exact-title match on the ticket's terminal list. A missing
22
+ worktree (`selector_not_found`) means "no session", not an error — that's
23
+ what lets bounce respawn after a crash.
24
+ - **Nudge** — waits for `tui-idle` (typed text mid-turn corrupts the input
25
+ box), then sends the prompt flattened to one line (keystroke simulation
26
+ submits on newline).
27
+ - **Close** — closes every terminal titled `<key>:*`; scaffolding tabs
28
+ ("Terminal 1", "Setup") survive.
29
+
30
+ ## Writing a new runner (tmux, ...)
31
+
32
+ 1. Create `internal/runner/<name>/` with:
33
+ ```go
34
+ func init() {
35
+ runner.Register("<name>", runner.Factory{
36
+ UnmarshalConfig: unmarshalConfig, // strict-decode runner.config (empty is fine)
37
+ New: func(cfg any) (runner.Runner, error) { ... },
38
+ })
39
+ }
40
+ ```
41
+ 2. Implement the interface:
42
+ ```go
43
+ type Runner interface {
44
+ Spawn(t tasks.Ticket, node, agent, prompt string, env map[string]string) error
45
+ Find(t tasks.Ticket, node string) (runner.Session, bool, error)
46
+ Nudge(s runner.Session, prompt string) error
47
+ Close(t tasks.Ticket) error
48
+ }
49
+ ```
50
+ Contract highlights:
51
+ - **Titles are identity**: sessions must be findable later by
52
+ `<key>:<agent>:<node>` — bounce depends on it.
53
+ - **env must reach the agent process** — the report plugin keys off
54
+ `RELAY_FLOW_WORKFLOW/TICKET/NODE/AGENT`.
55
+ - **Find must distinguish "gone" from "error"** — a missing session is a
56
+ normal bounce case (respawn), not a failure.
57
+ - The runner must still launch **opencode** inside whatever session it
58
+ creates — the report plugin is opencode-specific. (tmux example:
59
+ `tmux new-session -s key-agent-node` + `send-keys "RELAY_FLOW_*=... opencode --agent ..." Enter`.)
60
+ 3. If your runner needs the repo binding, implement
61
+ `WithRepo(repoID, repoName string, dryRun bool)` — the server calls it via
62
+ interface assertion when present.
63
+ 4. Import for side effects in `internal/server/server.go`.
64
+ 5. Tests with a fake CLI seam (see `orca_test.go` / the `orcaCLI` interface).
@@ -0,0 +1,243 @@
1
+ // Package orca is the built-in Runner adapter: it executes agent sessions
2
+ // as Orca terminals on per-ticket worktrees. It knows nothing about
3
+ // trackers — tickets arrive as tasks.Ticket values.
4
+ package orca
5
+
6
+ import (
7
+ "fmt"
8
+ "strings"
9
+ "time"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/opencode"
12
+ "github.com/rajpopat27/relay-flow/internal/orcacli"
13
+ "github.com/rajpopat27/relay-flow/internal/runner"
14
+ "github.com/rajpopat27/relay-flow/internal/tasks"
15
+ )
16
+
17
+ func init() {
18
+ runner.Register("orca", runner.Factory{
19
+ UnmarshalConfig: unmarshalConfig,
20
+ New: func(cfg any) (runner.Runner, error) {
21
+ c, ok := cfg.(Config)
22
+ if !ok {
23
+ return nil, fmt.Errorf("internal: orca factory received %T", cfg)
24
+ }
25
+ return NewRunner(c, nil), nil
26
+ },
27
+ })
28
+ }
29
+
30
+ // Config is the strictly-unmarshalled runner.config for type orca.
31
+ // Empty today — worktree ancestry details (repo, parent) are passed by
32
+ // the server at construction, not committed in YAML.
33
+ type Config struct{}
34
+
35
+ func unmarshalConfig(m map[string]any) (any, error) {
36
+ if len(m) > 0 {
37
+ for k := range m {
38
+ return nil, fmt.Errorf("unknown field %q (orca runner takes no config)", k)
39
+ }
40
+ }
41
+ return Config{}, nil
42
+ }
43
+
44
+ // orcaCLI is the seam to the orca CLI. *orcacli.Client satisfies it;
45
+ // tests fake it.
46
+ type orcaCLI interface {
47
+ WorktreeList() ([]orcacli.Worktree, error)
48
+ WorktreeCreate(ticketKey, repoID, parentWorktreeID, baseBranch string) error
49
+ FindWorktree(repoID, displayName string) (orcacli.Worktree, bool, error)
50
+ MainWorktree(repoID string) (orcacli.Worktree, bool, error)
51
+ TerminalList(worktree string) ([]orcacli.Terminal, error)
52
+ TerminalCreate(ticketKey, title, command string) (string, error)
53
+ TerminalWait(handle, forState string, timeoutMs int) error
54
+ TerminalClose(handle string) error
55
+ TerminalSend(handle, text string) error
56
+ }
57
+
58
+ type orcaRunner struct {
59
+ repoID string
60
+ repoName string // Jira component name; unused by the runner itself
61
+ dryRun bool
62
+ orca orcaCLI
63
+ exists func(string) (bool, error)
64
+ findBranch func(repoPath, key string) (string, bool, error)
65
+ sleep func(time.Duration) // test seam for ensureWorktree retries
66
+ }
67
+
68
+ // NewRunner builds the orca runner. oc nil → real orcacli client (dryRun
69
+ // plumbed through). repoID is set by WithRepo at submit time.
70
+ func NewRunner(_ Config, oc orcaCLI) runner.Runner {
71
+ r := &orcaRunner{
72
+ exists: opencode.Exists,
73
+ findBranch: orcacli.FindExistingBranch,
74
+ sleep: time.Sleep,
75
+ }
76
+ if oc != nil {
77
+ r.orca = oc
78
+ }
79
+ return r
80
+ }
81
+
82
+ // WithRepo binds the repo this runner serves (resolved server-side from
83
+ // the submitting client's cwd) and the dry-run flag.
84
+ func (r *orcaRunner) WithRepo(repoID, repoName string, dryRun bool) {
85
+ r.repoID, r.repoName, r.dryRun = repoID, repoName, dryRun
86
+ if r.orca == nil {
87
+ r.orca = orcacli.New(dryRun)
88
+ }
89
+ }
90
+
91
+ // title is the session identity: <key>:<agent>:<node>. Bounce and Close
92
+ // both match on it.
93
+ func title(t tasks.Ticket, node, agent string) string {
94
+ return fmt.Sprintf("%s:%s:%s", t.Key, agent, node)
95
+ }
96
+
97
+ // Spawn ensures the ticket's worktree exists, then creates a fresh
98
+ // terminal titled key:agent:node running opencode with the RELAY_FLOW_* env
99
+ // markers and the initial prompt. A fresh terminal per node visit:
100
+ // reusing an old session would leak the previous node's context.
101
+ func (r *orcaRunner) Spawn(t tasks.Ticket, node, agent, prompt string, env map[string]string) error {
102
+ if ok, err := r.exists(agent); err != nil {
103
+ return fmt.Errorf("verify opencode agent %q: %w", agent, err)
104
+ } else if !ok {
105
+ return fmt.Errorf("opencode agent %q does not exist", agent)
106
+ }
107
+ if err := r.ensureWorktree(t); err != nil {
108
+ return fmt.Errorf("ensure worktree: %w", err)
109
+ }
110
+ command := buildCommand(env, agent, prompt)
111
+ handle, err := r.orca.TerminalCreate(t.Key, title(t, node, agent), command)
112
+ if err != nil {
113
+ return fmt.Errorf("terminal create: %w", err)
114
+ }
115
+ // Best-effort: the wait only bounds how long Spawn blocks; the plugin
116
+ // report is the real synchronization.
117
+ _ = r.orca.TerminalWait(handle, "tui-idle", 10*60*1000)
118
+ return nil
119
+ }
120
+
121
+ // buildCommand renders the opencode invocation typed into the new
122
+ // terminal, with RELAY_FLOW_* env markers so the plugin can report back. A
123
+ // developer's own opencode session never has these set, so it never
124
+ // reports.
125
+ func buildCommand(env map[string]string, agent, prompt string) string {
126
+ parts := make([]string, 0, len(env))
127
+ // Deterministic order for logs/tests.
128
+ for _, k := range []string{"RELAY_FLOW_WORKFLOW", "RELAY_FLOW_TICKET", "RELAY_FLOW_NODE", "RELAY_FLOW_AGENT"} {
129
+ if v, ok := env[k]; ok {
130
+ parts = append(parts, k+"="+shellQuote(v))
131
+ }
132
+ }
133
+ return fmt.Sprintf("%s opencode --agent %s --prompt %s",
134
+ strings.Join(parts, " "), shellQuote(agent), shellQuote(prompt))
135
+ }
136
+
137
+ func shellQuote(s string) string {
138
+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
139
+ }
140
+
141
+ // Find locates the live session for ticket+node by exact title. A
142
+ // missing worktree (selector_not_found — e.g. claim label survived a
143
+ // crash but the worktree didn't) means "no session", not an error.
144
+ func (r *orcaRunner) Find(t tasks.Ticket, node string) (runner.Session, bool, error) {
145
+ terms, err := r.orca.TerminalList("name:" + t.Key)
146
+ if err != nil {
147
+ if strings.Contains(err.Error(), "selector_not_found") {
148
+ return runner.Session{}, false, nil
149
+ }
150
+ return runner.Session{}, false, fmt.Errorf("terminal list: %w", err)
151
+ }
152
+ want := t.Key + ":"
153
+ for _, term := range terms {
154
+ if strings.HasPrefix(term.Title, want) && strings.HasSuffix(term.Title, ":"+node) {
155
+ return runner.Session{ID: term.Handle, Title: term.Title}, true, nil
156
+ }
157
+ }
158
+ return runner.Session{}, false, nil
159
+ }
160
+
161
+ // Nudge types a prompt into an existing session. The caller must have
162
+ // waited for idle first — typed text mid-turn corrupts the input box.
163
+ func (r *orcaRunner) Nudge(s runner.Session, prompt string) error {
164
+ flat := strings.Join(strings.Fields(prompt), " ")
165
+ if err := r.orca.TerminalWait(s.ID, "tui-idle", 3000); err != nil {
166
+ return fmt.Errorf("session %q busy, nudge not delivered", s.Title)
167
+ }
168
+ return r.orca.TerminalSend(s.ID, flat)
169
+ }
170
+
171
+ // Close tears down every terminal titled <key>:* on the ticket's
172
+ // worktree. Scaffolding tabs ("Terminal 1", "Setup") are not ours and
173
+ // survive.
174
+ func (r *orcaRunner) Close(t tasks.Ticket) error {
175
+ terms, err := r.orca.TerminalList("name:" + t.Key)
176
+ if err != nil {
177
+ return fmt.Errorf("terminal list: %w", err)
178
+ }
179
+ prefix := t.Key + ":"
180
+ for _, term := range terms {
181
+ if strings.HasPrefix(term.Title, prefix) {
182
+ if err := r.orca.TerminalClose(term.Handle); err != nil {
183
+ return fmt.Errorf("close %q: %w", term.Title, err)
184
+ }
185
+ }
186
+ }
187
+ return nil
188
+ }
189
+
190
+ // ensureWorktree creates the ticket's worktree if missing, verifying the
191
+ // exact name landed (Orca silently auto-suffixes on collisions).
192
+ func (r *orcaRunner) ensureWorktree(t tasks.Ticket) error {
193
+ for attempt := 0; attempt < 3; attempt++ {
194
+ if _, ok, err := r.orca.FindWorktree(r.repoID, t.Key); err != nil {
195
+ return err
196
+ } else if ok {
197
+ return nil
198
+ }
199
+ if attempt < 2 {
200
+ r.sleep(2 * time.Second)
201
+ }
202
+ }
203
+ parentID, baseBranch, err := r.resolveWorktreeParent(t)
204
+ if err != nil {
205
+ return err
206
+ }
207
+ if err := r.orca.WorktreeCreate(t.Key, r.repoID, parentID, baseBranch); err != nil {
208
+ return err
209
+ }
210
+ for attempt := 0; attempt < 3; attempt++ {
211
+ if _, ok, err := r.orca.FindWorktree(r.repoID, t.Key); err != nil {
212
+ return err
213
+ } else if ok {
214
+ return nil
215
+ }
216
+ if attempt < 2 {
217
+ r.sleep(2 * time.Second)
218
+ }
219
+ }
220
+ return fmt.Errorf("worktree %q not found after create (Orca likely auto-suffixed it on a name/branch collision) — clean up the suffixed worktree/branch manually", t.Key)
221
+ }
222
+
223
+ // resolveWorktreeParent picks the ancestry for a new ticket worktree:
224
+ // main worktree's branch, reused ticket branch if one exists. (The
225
+ // baseBranch-label and subtask-parent rules from v3 need ticket labels/
226
+ // parent info that tasks.Ticket doesn't carry — deferred; YAGNI until a
227
+ // tracker adapter exposes them.)
228
+ func (r *orcaRunner) resolveWorktreeParent(t tasks.Ticket) (parentWorktreeID, baseBranch string, err error) {
229
+ w, ok, err := r.orca.MainWorktree(r.repoID)
230
+ if err != nil {
231
+ return "", "", err
232
+ }
233
+ if !ok {
234
+ return "", "", fmt.Errorf("could not find main worktree for repo %s", r.repoID)
235
+ }
236
+ // A branch for this ticket may already exist (left over from a
237
+ // removed worktree). Reuse it — Orca silently renames the worktree on
238
+ // branch collision, desyncing every ticket-key lookup.
239
+ if existing, ok, err := r.findBranch(w.Path, t.Key); err == nil && ok {
240
+ return w.ID, existing, nil
241
+ }
242
+ return w.ID, w.Branch, nil
243
+ }