relay-flow 0.0.1 → 0.2.0-alpha

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.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. package/internal/tasks/tasks_test.go +0 -91
@@ -1,243 +1,436 @@
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.
1
+ // Package orca is the Orca runner adapter. It owns ticket-scoped worktrees
2
+ // (environments), terminals, liveness, and cleanup. It does not know
3
+ // task-system fields, workflow routes, report contents, or agent command
4
+ // syntax.
5
+ //
6
+ // 9.5 external-call logging: every adapter boundary emits one debug line
7
+ // BEFORE the call (operation, ticket/runID, title when applicable) and one
8
+ // info line AFTER with only the outcome (ok/error), never payloads.
4
9
  package orca
5
10
 
6
11
  import (
12
+ "context"
13
+ "errors"
7
14
  "fmt"
15
+ "log/slog"
8
16
  "strings"
9
- "time"
10
17
 
11
- "github.com/rajpopat27/relay-flow/internal/opencode"
12
- "github.com/rajpopat27/relay-flow/internal/orcacli"
18
+ "github.com/rajpopat27/relay-flow/internal/config"
13
19
  "github.com/rajpopat27/relay-flow/internal/runner"
14
- "github.com/rajpopat27/relay-flow/internal/tasks"
20
+ "github.com/rajpopat27/relay-flow/internal/runner/orca/orcacli"
15
21
  )
16
22
 
23
+ // Config is the adapter-owned root runnerConfig.
24
+ type Config struct {
25
+ // BaseRef is the base branch for ticket worktrees. When empty, the base
26
+ // branch is derived from the repo's primary worktree reported by Orca.
27
+ BaseRef string `yaml:"baseRef,omitempty"`
28
+ }
29
+
17
30
  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
- },
31
+ runner.Register("orca", func(raw config.RawValues) (runner.Runner, error) {
32
+ return New(orcacli.New(), raw)
27
33
  })
28
34
  }
29
35
 
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{}
36
+ // adapter is the Orca runner.Runner. It is safe for concurrent use; the CLI
37
+ // client owns subprocess serialization.
38
+ type adapter struct {
39
+ cli orcacli.Client
40
+ cfg Config
41
+ }
34
42
 
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
- }
43
+ // New constructs the adapter from root runnerConfig around an explicit CLI
44
+ // seam (tests inject a fake Client).
45
+ func New(cli orcacli.Client, raw config.RawValues) (runner.Runner, error) {
46
+ var cfg Config
47
+ if err := config.DecodeStrict(raw, &cfg); err != nil {
48
+ return nil, fmt.Errorf("orca runnerConfig: %w", err)
40
49
  }
41
- return Config{}, nil
50
+ return &adapter{cli: cli, cfg: cfg}, nil
42
51
  }
43
52
 
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
53
+ // --- Repos ---
54
+
55
+ // DiscoverRepos returns Orca-registered repos as registration candidates.
56
+ func (a *adapter) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
57
+ repos, err := a.cli.ListRepos(ctx)
58
+ if err != nil {
59
+ return nil, err
60
+ }
61
+ out := make([]runner.RepoCandidate, 0, len(repos))
62
+ for _, r := range repos {
63
+ out = append(out, runner.RepoCandidate{Name: r.DisplayName, Path: r.Path})
64
+ }
65
+ return out, nil
56
66
  }
57
67
 
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
68
+ // ValidateRepo verifies the named repo exists in Orca at the given path.
69
+ func (a *adapter) ValidateRepo(ctx context.Context, name, path string) error {
70
+ if _, err := a.repoID(ctx, name, path); err != nil {
71
+ return err
72
+ }
73
+ return nil
66
74
  }
67
75
 
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,
76
+ // repoID resolves a registered repo to its Orca repo ID. The repo path is
77
+ // the stable identity (the Orca repo ID is an internal detail that can
78
+ // change across machines); name is matched as a secondary check.
79
+ func (a *adapter) repoID(ctx context.Context, name, path string) (string, error) {
80
+ repos, err := a.cli.ListRepos(ctx)
81
+ if err != nil {
82
+ return "", err
75
83
  }
76
- if oc != nil {
77
- r.orca = oc
84
+ for _, r := range repos {
85
+ if r.Path == path && r.DisplayName == name {
86
+ return r.ID, nil
87
+ }
78
88
  }
79
- return r
89
+ return "", fmt.Errorf("orca: repo %q at %q not registered", name, path)
80
90
  }
81
91
 
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)
92
+ // --- Environment ---
93
+
94
+ // EnsureEnvironment returns the ticket-scoped worktree, creating it from the
95
+ // repo's main worktree and the configured base ref (or the primary
96
+ // worktree's branch) when absent.
97
+ func (a *adapter) EnsureEnvironment(ctx context.Context, spec runner.RunSpec) (runner.Environment, error) {
98
+ slog.Debug("orca call",
99
+ "op", "ensure-environment", "ticket", spec.TicketKey,
100
+ "runID", string(spec.RunID), "repo", spec.RepoName)
101
+ env, reused, err := a.ensureEnvironment(ctx, spec)
102
+ attrs := []any{
103
+ "op", "ensure-environment", "ticket", spec.TicketKey,
104
+ "runID", string(spec.RunID), "repo", spec.RepoName,
105
+ }
106
+ if err != nil {
107
+ attrs = append(attrs, "result", "error", "error", sanitizeErr(err))
108
+ } else if reused {
109
+ attrs = append(attrs, "result", "exists")
110
+ } else {
111
+ attrs = append(attrs, "result", "created")
88
112
  }
113
+ slog.Info("orca outcome", attrs...)
114
+ return env, err
89
115
  }
90
116
 
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)
117
+ // ensureEnvironment is the unlogged body factored out so the public method
118
+ // can emit one outcome line (created/exists/error) without duplicate
119
+ // logging on the inner re-reads.
120
+ func (a *adapter) ensureEnvironment(ctx context.Context, spec runner.RunSpec) (runner.Environment, bool, error) {
121
+ repoID, err := a.repoID(ctx, spec.RepoName, spec.RepoPath)
122
+ if err != nil {
123
+ return runner.Environment{}, false, err
124
+ }
125
+ wts, err := a.cli.ListWorktrees(ctx)
126
+ if err != nil {
127
+ return runner.Environment{}, false, err
128
+ }
129
+ var main *orcacli.Worktree
130
+ for i := range wts {
131
+ w := &wts[i]
132
+ if w.RepoID != repoID {
133
+ continue
134
+ }
135
+ if w.DisplayName == spec.TicketKey {
136
+ return runner.Environment{ID: w.ID, Path: w.Path}, true, nil
137
+ }
138
+ if w.IsMainWorktree {
139
+ main = w
140
+ }
141
+ }
142
+ if main == nil {
143
+ return runner.Environment{}, false, fmt.Errorf("orca: repo %q has no main worktree", spec.RepoName)
144
+ }
145
+ baseRef := a.cfg.BaseRef
146
+ if existing, ok, findErr := orcacli.FindExistingBranch(main.Path, spec.TicketKey); findErr == nil && ok {
147
+ baseRef = existing
148
+ } else if baseRef == "" {
149
+ baseRef = primaryBranch(main)
150
+ }
151
+ if err := a.cli.CreateWorktree(ctx, spec.TicketKey, repoID, main.ID, baseRef); err != nil {
152
+ return runner.Environment{}, false, err
153
+ }
154
+ // Re-read to return the created worktree's identity.
155
+ wts, err = a.cli.ListWorktrees(ctx)
156
+ if err != nil {
157
+ return runner.Environment{}, false, err
158
+ }
159
+ for _, w := range wts {
160
+ if w.RepoID == repoID && w.DisplayName == spec.TicketKey {
161
+ return runner.Environment{ID: w.ID, Path: w.Path}, false, nil
162
+ }
163
+ }
164
+ return runner.Environment{}, false, fmt.Errorf("orca: worktree %q not found after create", spec.TicketKey)
95
165
  }
96
166
 
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)
167
+ // primaryBranch derives the base branch name from the repo's primary
168
+ // worktree reported by Orca. Orca returns a fully-qualified ref like
169
+ // "refs/heads/master"; normalize to the bare branch name. When the
170
+ // worktree's branch is empty (no primary branch recorded), fall back to
171
+ // "main".
172
+ func primaryBranch(w *orcacli.Worktree) string {
173
+ const prefix = "refs/heads/"
174
+ b := w.Branch
175
+ if strings.HasPrefix(b, prefix) {
176
+ b = strings.TrimPrefix(b, prefix)
106
177
  }
107
- if err := r.ensureWorktree(t); err != nil {
108
- return fmt.Errorf("ensure worktree: %w", err)
178
+ if b == "" {
179
+ return "main"
109
180
  }
110
- command := buildCommand(env, agent, prompt)
111
- handle, err := r.orca.TerminalCreate(t.Key, title(t, node, agent), command)
181
+ return b
182
+ }
183
+
184
+ // --- Terminals ---
185
+
186
+ // FindTerminal returns the terminal titled exactly title in the environment
187
+ // when it is live and usable; stale/disconnected records are treated as
188
+ // absent.
189
+ func (a *adapter) FindTerminal(ctx context.Context, env runner.Environment, title string) (runner.Terminal, bool, error) {
190
+ slog.Debug("orca call", "op", "find-terminal", "title", title, "envID", env.ID)
191
+ terms, err := a.cli.ListTerminals(ctx, "id:"+env.ID)
112
192
  if err != nil {
113
- return fmt.Errorf("terminal create: %w", err)
193
+ slog.Info("orca outcome", "op", "find-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
194
+ return runner.Terminal{}, false, err
114
195
  }
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
196
+ for _, t := range terms {
197
+ if t.Title == title && t.Connected {
198
+ slog.Info("orca outcome", "op", "find-terminal", "title", title, "result", "found")
199
+ return runner.Terminal{ID: t.Handle, Title: t.Title}, true, nil
200
+ }
201
+ }
202
+ slog.Info("orca outcome", "op", "find-terminal", "title", title, "result", "absent")
203
+ return runner.Terminal{}, false, nil
119
204
  }
120
205
 
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
- }
206
+ // CloseTerminal closes one terminal by handle.
207
+ func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) error {
208
+ slog.Debug("orca call", "op", "close-terminal", "title", terminal.Title, "handle", terminal.ID)
209
+ err := a.cli.CloseTerminal(ctx, terminal.ID)
210
+ if err != nil {
211
+ slog.Info("orca outcome", "op", "close-terminal", "title", terminal.Title, "result", "error", "error", sanitizeErr(err))
212
+ } else {
213
+ slog.Info("orca outcome", "op", "close-terminal", "title", terminal.Title, "result", "ok")
132
214
  }
133
- return fmt.Sprintf("%s opencode --agent %s --prompt %s",
134
- strings.Join(parts, " "), shellQuote(agent), shellQuote(prompt))
215
+ return err
135
216
  }
136
217
 
137
- func shellQuote(s string) string {
138
- return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
218
+ // InspectTerminal addresses a persisted handle directly. Normal execution
219
+ // never lists terminals or rediscovers by title.
220
+ func (a *adapter) InspectTerminal(ctx context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
221
+ t, err := a.cli.ShowTerminal(ctx, terminal.ID)
222
+ if errors.Is(err, orcacli.ErrTerminalUnavailable) {
223
+ return runner.Terminal{}, false, nil
224
+ }
225
+ if err != nil {
226
+ return runner.Terminal{}, false, err
227
+ }
228
+ if !t.Connected {
229
+ return runner.Terminal{}, false, nil
230
+ }
231
+ return runner.Terminal{ID: t.Handle, Title: t.Title}, true, nil
232
+ }
233
+
234
+ func (a *adapter) SendTerminal(ctx context.Context, terminal runner.Terminal, text string) error {
235
+ return a.cli.SendTerminal(ctx, terminal.ID, text)
139
236
  }
140
237
 
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)
238
+ // CreateTerminal always creates a terminal; it performs no title discovery.
239
+ func (a *adapter) CreateTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
240
+ name := strings.SplitN(title, ":", 2)[0]
241
+ handle, err := a.cli.CreateTerminal(ctx, name, title, shellCommand(command))
146
242
  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)
243
+ return runner.Terminal{}, err
151
244
  }
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
245
+ if commandResumesSession(command) {
246
+ t, showErr := a.cli.ShowTerminal(ctx, handle)
247
+ if errors.Is(showErr, orcacli.ErrTerminalUnavailable) || (showErr == nil && !t.Connected) {
248
+ _ = a.cli.CloseTerminal(ctx, handle)
249
+ return runner.Terminal{}, runner.ErrSessionUnavailable
250
+ }
251
+ if showErr != nil {
252
+ return runner.Terminal{}, showErr
156
253
  }
157
254
  }
158
- return runner.Session{}, false, nil
255
+ return runner.Terminal{ID: handle, Title: title}, nil
159
256
  }
160
257
 
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)
258
+ func commandResumesSession(command runner.Command) bool {
259
+ for i, arg := range command.Args {
260
+ if arg == "--session" && i+1 < len(command.Args) && command.Args[i+1] != "" {
261
+ return true
262
+ }
167
263
  }
168
- return r.orca.TerminalSend(s.ID, flat)
264
+ return false
169
265
  }
170
266
 
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)
267
+ // EnsureTerminal is idempotent: it returns the live terminal with the stable
268
+ // title when present, otherwise creates one running command. The title
269
+ // contains only <ticket>:<node>; visit metadata lives in the command's
270
+ // environment, never the title.
271
+ func (a *adapter) EnsureTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
272
+ slog.Debug("orca call", "op", "ensure-terminal", "title", title, "envID", env.ID)
273
+ if t, ok, err := a.FindTerminal(ctx, env, title); err != nil {
274
+ slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
275
+ return runner.Terminal{}, err
276
+ } else if ok {
277
+ slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "exists")
278
+ return t, nil
279
+ }
280
+ // The worktree display name is the ticket key (EnsureEnvironment names
281
+ // it after the ticket).
282
+ name := strings.SplitN(title, ":", 2)[0]
283
+ handle, err := a.cli.CreateTerminal(ctx, name, title, shellCommand(command))
176
284
  if err != nil {
177
- return fmt.Errorf("terminal list: %w", err)
285
+ slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
286
+ return runner.Terminal{}, err
178
287
  }
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
- }
288
+ slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "created")
289
+ return runner.Terminal{ID: handle, Title: title}, nil
290
+ }
291
+
292
+ // sanitizeErr strips the leading "orca [args...]:" prefix from orcacli
293
+ // errors so info-level outcome lines never leak argv payloads (notably the
294
+ // --command string built by shellCommand, which carries the agent prompt
295
+ // and RELAY_FLOW_* env). Keeps the trailing stderr/exit fragment.
296
+ func sanitizeErr(err error) string {
297
+ if err == nil {
298
+ return ""
299
+ }
300
+ s := err.Error()
301
+ // "orca terminal create: orca [args...]: <err>: <out>" — drop the
302
+ // "[args...]" middle.
303
+ if i := strings.Index(s, "]: "); i >= 0 {
304
+ // Keep any "orca <words>:" prefix before the "[" (e.g. the
305
+ // "orca terminal create:" wrap), then append the post-] tail.
306
+ prefix := ""
307
+ if j := strings.Index(s, "["); j > 0 {
308
+ prefix = strings.TrimSuffix(s[:j], " ")
309
+ }
310
+ if prefix != "" {
311
+ return prefix + "]: " + s[i+3:]
185
312
  }
313
+ return s[i+3:]
186
314
  }
187
- return nil
315
+ return s
316
+ }
317
+
318
+ // shellCommand renders the structured command as one shell line with env
319
+ // assignments; the runner executes it but never constructs it.
320
+ func shellCommand(c runner.Command) string {
321
+ var b strings.Builder
322
+ for _, k := range sortedKeys(c.Env) {
323
+ b.WriteString(k)
324
+ b.WriteString("=")
325
+ b.WriteString(shellQuote(c.Env[k]))
326
+ b.WriteString(" ")
327
+ }
328
+ b.WriteString(shellQuote(c.Executable))
329
+ for _, arg := range c.Args {
330
+ b.WriteString(" ")
331
+ b.WriteString(shellQuote(arg))
332
+ }
333
+ return b.String()
188
334
  }
189
335
 
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
336
+ func shellQuote(s string) string {
337
+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
338
+ }
339
+
340
+ func sortedKeys(m map[string]string) []string {
341
+ keys := make([]string, 0, len(m))
342
+ for k := range m {
343
+ keys = append(keys, k)
344
+ }
345
+ for i := 1; i < len(keys); i++ {
346
+ for j := i; j > 0 && keys[j] < keys[j-1]; j-- {
347
+ keys[j], keys[j-1] = keys[j-1], keys[j]
198
348
  }
199
- if attempt < 2 {
200
- r.sleep(2 * time.Second)
349
+ }
350
+ return keys
351
+ }
352
+
353
+ // --- Cleanup ---
354
+
355
+ // CloseTerminals closes the run's agent terminals while preserving the
356
+ // worktree and any non-run tabs (setup, user shells). Run-owned terminals
357
+ // are identified by the stable <ticket>:<node> title prefix.
358
+ func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error {
359
+ slog.Debug("orca call", "op", "close-terminals", "ticket", spec.TicketKey, "runID", string(spec.RunID))
360
+ env, ok, err := a.findEnvironment(ctx, spec)
361
+ if err != nil || !ok {
362
+ if err != nil {
363
+ slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
364
+ } else {
365
+ slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "no-environment")
201
366
  }
367
+ return err
202
368
  }
203
- parentID, baseBranch, err := r.resolveWorktreeParent(t)
369
+ terms, err := a.cli.ListTerminals(ctx, "id:"+env.ID)
204
370
  if err != nil {
371
+ slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
205
372
  return err
206
373
  }
207
- if err := r.orca.WorktreeCreate(t.Key, r.repoID, parentID, baseBranch); err != nil {
374
+ prefix := spec.TicketKey + ":"
375
+ closed := 0
376
+ for _, t := range terms {
377
+ if !strings.HasPrefix(t.Title, prefix) {
378
+ continue
379
+ }
380
+ // 9.5: one outcome line per actual terminal close, with its title.
381
+ slog.Debug("orca call", "op", "close-terminal", "title", t.Title, "handle", t.Handle)
382
+ if err := a.cli.CloseTerminal(ctx, t.Handle); err != nil {
383
+ slog.Info("orca outcome", "op", "close-terminal", "title", t.Title, "result", "error", "error", sanitizeErr(err))
384
+ slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
385
+ return fmt.Errorf("close terminal %q: %w", t.Title, err)
386
+ }
387
+ slog.Info("orca outcome", "op", "close-terminal", "title", t.Title, "result", "ok")
388
+ closed++
389
+ }
390
+ slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "ok", "closed", closed)
391
+ return nil
392
+ }
393
+
394
+ // CleanupRun removes all runner-owned run resources: terminals, then the
395
+ // ticket worktree itself.
396
+ func (a *adapter) CleanupRun(ctx context.Context, spec runner.RunSpec) error {
397
+ slog.Debug("orca call", "op", "cleanup-run", "ticket", spec.TicketKey, "runID", string(spec.RunID))
398
+ if err := a.CloseTerminals(ctx, spec); err != nil {
399
+ slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
208
400
  return err
209
401
  }
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)
402
+ env, ok, err := a.findEnvironment(ctx, spec)
403
+ if err != nil || !ok {
404
+ if err != nil {
405
+ slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
406
+ } else {
407
+ slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "no-environment")
218
408
  }
409
+ return err
410
+ }
411
+ err = a.cli.DeleteWorktree(ctx, env.ID)
412
+ if err != nil {
413
+ slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
414
+ } else {
415
+ slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "ok")
219
416
  }
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)
417
+ return err
221
418
  }
222
419
 
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)
420
+ // findEnvironment locates the ticket worktree without creating it.
421
+ func (a *adapter) findEnvironment(ctx context.Context, spec runner.RunSpec) (runner.Environment, bool, error) {
422
+ repoID, err := a.repoID(ctx, spec.RepoName, spec.RepoPath)
230
423
  if err != nil {
231
- return "", "", err
424
+ return runner.Environment{}, false, err
232
425
  }
233
- if !ok {
234
- return "", "", fmt.Errorf("could not find main worktree for repo %s", r.repoID)
426
+ wts, err := a.cli.ListWorktrees(ctx)
427
+ if err != nil {
428
+ return runner.Environment{}, false, err
235
429
  }
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
430
+ for _, w := range wts {
431
+ if w.RepoID == repoID && w.DisplayName == spec.TicketKey {
432
+ return runner.Environment{ID: w.ID, Path: w.Path}, true, nil
433
+ }
241
434
  }
242
- return w.ID, w.Branch, nil
435
+ return runner.Environment{}, false, nil
243
436
  }