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
@@ -0,0 +1,490 @@
1
+ package goworkflows
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "log/slog"
8
+ "sort"
9
+ "strings"
10
+ "time"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/config"
13
+ "github.com/rajpopat27/relay-flow/internal/harness"
14
+ "github.com/rajpopat27/relay-flow/internal/repo"
15
+ "github.com/rajpopat27/relay-flow/internal/run"
16
+ "github.com/rajpopat27/relay-flow/internal/runner"
17
+ "github.com/rajpopat27/relay-flow/internal/task"
18
+ "github.com/rajpopat27/relay-flow/internal/workflow"
19
+ )
20
+
21
+ // Activities holds the replaceable dependencies shared by every durable
22
+ // activity. One Activities value is registered with the activity worker.
23
+ type Activities struct {
24
+ Repos *repo.Registry
25
+ Runner runner.Runner
26
+ Harness harness.Harness
27
+ Runs *RunProjection
28
+ }
29
+
30
+ func (a *Activities) taskSystem(repoName string) (task.System, error) {
31
+ rp, ok := a.Repos.Get(repoName)
32
+ if !ok {
33
+ return nil, fmt.Errorf("repo %q is not registered", repoName)
34
+ }
35
+ return rp.TaskSystem, nil
36
+ }
37
+
38
+ func (a *Activities) runSpec(w run.Work) runner.RunSpec {
39
+ return runner.RunSpec{
40
+ RunID: w.RunID,
41
+ RepoName: w.Repo,
42
+ RepoPath: "",
43
+ TicketKey: w.Parent.Key,
44
+ }
45
+ }
46
+
47
+ // EnsureMailboxes ensures one mailbox per work node and returns the
48
+ // node-to-mailbox map.
49
+ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
50
+ sys, err := a.taskSystem(w.Repo)
51
+ if err != nil {
52
+ return nil, err
53
+ }
54
+ return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
55
+ }
56
+
57
+ // ValidateAgents validates every referenced agent on the repo.
58
+ func (a *Activities) ValidateAgents(ctx context.Context, repoPath string, agents []string) error {
59
+ for _, agent := range agents {
60
+ if err := a.Harness.ValidateAgent(ctx, repoPath, agent); err != nil {
61
+ return fmt.Errorf("validate agent %q: %w", agent, err)
62
+ }
63
+ }
64
+ return nil
65
+ }
66
+
67
+ // ApplyTaskConfig applies adapter-owned task config to the parent and
68
+ // optional mailbox target.
69
+ func (a *Activities) ApplyTaskConfig(ctx context.Context, w run.Work, node string, mailbox *task.Mailbox, cfg map[string]any) error {
70
+ sys, err := a.taskSystem(w.Repo)
71
+ if err != nil {
72
+ return err
73
+ }
74
+ // Adapters with lifecycle-dependent taskConfig defaults (e.g. Jira
75
+ // transitionTo) expose them via task.LifecycleDefaults; merge them as
76
+ // the lowest layer under the effective node config so explicit values
77
+ // win. Core never learns adapter vocabulary.
78
+ if d, ok := sys.(task.LifecycleDefaults); ok {
79
+ var defaults config.RawValues
80
+ switch {
81
+ case node == "start":
82
+ defaults = d.StartDefaults()
83
+ case node == "end":
84
+ defaults = d.EndDefaults()
85
+ default:
86
+ defaults = d.WorkDefaults()
87
+ }
88
+ cfg = map[string]any(config.Merge(defaults, cfg))
89
+ }
90
+ return sys.ApplyTaskConfig(ctx, task.Target{Parent: w.Parent, Mailbox: mailbox}, cfg)
91
+ }
92
+
93
+ // EnsureEnvironment ensures the ticket-scoped runner environment.
94
+ func (a *Activities) EnsureEnvironment(ctx context.Context, w run.Work, repoPath string) (runner.Environment, error) {
95
+ spec := a.runSpec(w)
96
+ spec.RepoPath = repoPath
97
+ return a.Runner.EnsureEnvironment(ctx, spec)
98
+ }
99
+
100
+ func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
101
+ return a.Runs.loadNodeRuntime(ctx, id, node)
102
+ }
103
+
104
+ // EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
105
+ // path. A live terminal is rebound to the new visit; otherwise a fresh
106
+ // terminal is created and its direct IDs atomically replace the old binding.
107
+ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) error {
108
+ a.Runs.runtimeMu.Lock()
109
+ defer a.Runs.runtimeMu.Unlock()
110
+ slog.Info("node entered",
111
+ "ticket", nw.Parent.Key, "runID", string(nw.RunID),
112
+ "repo", nw.Repo, "workflow", nw.Workflow,
113
+ "node", nw.Node, "nodeVisitID", string(nw.NodeVisitID),
114
+ "nodeType", string(spec.NodeType), "agent", spec.Agent)
115
+ revisit := rt.NodeVisitID != "" && rt.NodeVisitID != spec.NodeVisitID
116
+ current, err := a.Runs.nodeRuntimeVisitIsCurrent(ctx, nw.RunID, nw.Node, nw.NodeVisitID)
117
+ if err != nil {
118
+ return err
119
+ }
120
+ if !current {
121
+ return fmt.Errorf("node runtime %s/%s visit %s is stale", nw.RunID, nw.Node, nw.NodeVisitID)
122
+ }
123
+ currentRuntime, err := a.Runs.loadNodeRuntime(ctx, nw.RunID, nw.Node)
124
+ if err != nil {
125
+ return err
126
+ }
127
+ hadRuntime := currentRuntime.TerminalID != "" || currentRuntime.SessionID != ""
128
+ // IDs come from the guarded current row; the activity input's prior visit
129
+ // is used only to decide whether a live process needs rebinding.
130
+ rt.TerminalID = currentRuntime.TerminalID
131
+ rt.SessionID = currentRuntime.SessionID
132
+ freshSession := false
133
+ if rt.TerminalID != "" {
134
+ terminal := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
135
+ _, ok, inspectErr := a.Runner.InspectTerminal(ctx, terminal)
136
+ if inspectErr != nil {
137
+ return inspectErr
138
+ }
139
+ if ok {
140
+ // Same-visit retry/restart leaves the running turn untouched. A
141
+ // revisit sends only the new work prompt to the retained session.
142
+ if !revisit {
143
+ return a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
144
+ rt.TerminalID, rt.SessionID, rt.SessionID)
145
+ }
146
+ prompt := followUpPrompt(nw.Mailbox.Key)
147
+ if spec.NudgePrompt != "" {
148
+ prompt += "\n\n" + spec.NudgePrompt
149
+ }
150
+ if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
151
+ return a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
152
+ rt.TerminalID, rt.SessionID, rt.SessionID)
153
+ }
154
+ // Direct use failed. Close the known live terminal before replacing
155
+ // it so a second agent process cannot be left running.
156
+ if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
157
+ return err
158
+ }
159
+ freshSession = true
160
+ }
161
+ }
162
+
163
+ if rt.SessionID != "" && !freshSession {
164
+ spec.ResumeID = rt.SessionID
165
+ }
166
+ // Custom instructions belong to a node entry, not same-visit recovery.
167
+ if !hadRuntime || revisit {
168
+ spec.Prompt = appendPrompt(spec.Prompt, spec.NudgePrompt)
169
+ }
170
+ cmd, err := a.Harness.BuildCommand(spec)
171
+ if err != nil {
172
+ return err
173
+ }
174
+ rs := a.runSpec(nw.Work)
175
+ rs.RepoPath = repoPath
176
+ env, err := a.Runner.EnsureEnvironment(ctx, rs)
177
+ if err != nil {
178
+ return err
179
+ }
180
+ terminal, err := a.Runner.CreateTerminal(ctx, env, spec.Title, cmd)
181
+ sessionID := rt.SessionID
182
+ if errors.Is(err, runner.ErrSessionUnavailable) && rt.SessionID != "" {
183
+ spec.ResumeID = ""
184
+ cmd, buildErr := a.Harness.BuildCommand(spec)
185
+ if buildErr != nil {
186
+ return buildErr
187
+ }
188
+ terminal, err = a.Runner.CreateTerminal(ctx, env, spec.Title, cmd)
189
+ sessionID = ""
190
+ }
191
+ if freshSession {
192
+ sessionID = ""
193
+ }
194
+ if err != nil {
195
+ return err
196
+ }
197
+ if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
198
+ terminal.ID, rt.SessionID, sessionID); err != nil {
199
+ // The visit changed after terminal creation. Close the terminal whose
200
+ // binding was rejected so stale work cannot leak or report.
201
+ _ = a.Runner.CloseTerminal(ctx, terminal)
202
+ return err
203
+ }
204
+ return nil
205
+ }
206
+
207
+ // CloseTerminals closes run-owned agent terminals, preserving the
208
+ // environment/workspace.
209
+ func (a *Activities) CloseTerminals(ctx context.Context, w run.Work, repoPath string) error {
210
+ spec := a.runSpec(w)
211
+ spec.RepoPath = repoPath
212
+ return a.Runner.CloseTerminals(ctx, spec)
213
+ }
214
+
215
+ func (a *Activities) CheckpointNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, policy run.RuntimePolicy) error {
216
+ a.Runs.runtimeMu.Lock()
217
+ defer a.Runs.runtimeMu.Unlock()
218
+ rt, err := a.Runs.loadNodeRuntime(ctx, nw.RunID, nw.Node)
219
+ if err != nil {
220
+ return err
221
+ }
222
+ if !policy.KeepTerminalsAlive && rt.TerminalID != "" {
223
+ if err := a.Runner.CloseTerminal(ctx, runner.Terminal{ID: rt.TerminalID, Title: nw.Parent.Key + ":" + nw.Node}); err != nil {
224
+ return err
225
+ }
226
+ }
227
+ return a.Runs.clearNodeRuntime(ctx, nw.RunID, nw.Node,
228
+ !policy.KeepTerminalsAlive, !policy.KeepSessionsAlive)
229
+ }
230
+
231
+ func (a *Activities) FinalizeNodeRuntimes(ctx context.Context, w run.Work, repoPath string, policy run.RuntimePolicy) error {
232
+ a.Runs.runtimeMu.Lock()
233
+ defer a.Runs.runtimeMu.Unlock()
234
+ runtimes, err := a.Runs.listNodeRuntimes(ctx, w.RunID)
235
+ if err != nil {
236
+ return err
237
+ }
238
+ if !policy.KeepTerminalsAlive {
239
+ for _, rt := range runtimes {
240
+ if rt.TerminalID == "" {
241
+ continue
242
+ }
243
+ if err := a.Runner.CloseTerminal(ctx, runner.Terminal{ID: rt.TerminalID, Title: w.Parent.Key + ":" + rt.Node}); err != nil {
244
+ return err
245
+ }
246
+ }
247
+ }
248
+ for _, rt := range runtimes {
249
+ if err := a.Runs.clearNodeRuntime(ctx, w.RunID, rt.Node,
250
+ !policy.KeepTerminalsAlive, !policy.KeepSessionsAlive); err != nil {
251
+ return err
252
+ }
253
+ }
254
+ return nil
255
+ }
256
+
257
+ // CleanupRun removes all runner-owned run resources at end.
258
+ func (a *Activities) CleanupRun(ctx context.Context, w run.Work, repoPath string) error {
259
+ spec := a.runSpec(w)
260
+ spec.RepoPath = repoPath
261
+ return a.Runner.CleanupRun(ctx, spec)
262
+ }
263
+
264
+ // Comment writes a marked comment to the target on the run's repo.
265
+ //
266
+ // 9.3 transition logging: the interpreter uses markers of the form
267
+ // "<nodeVisitID>:summary" for the current node summary and
268
+ // "<nodeVisitID>:feedback" for the selected-next feedback, so this
269
+ // activity emits one info line per effect with the same ticket/runID/node
270
+ // attrs as the rest of the run. Cancellation markers and other comments
271
+ // still pass through silently (no transition effect).
272
+ func (a *Activities) Comment(ctx context.Context, repoName string, cw run.CommentWork) error {
273
+ sys, err := a.taskSystem(repoName)
274
+ if err != nil {
275
+ return err
276
+ }
277
+ if err := sys.Comment(ctx, cw.Item, cw.Body, cw.Marker); err != nil {
278
+ return err
279
+ }
280
+ // Log AFTER the write succeeds so the line is a true effect record.
281
+ // marker is "<nodeVisitID>:summary" | "<nodeVisitID>:feedback" |
282
+ // "<runID>:cancellation". Only the first two are transition effects.
283
+ visit, tag := splitMarker(cw.Marker)
284
+ if tag != "summary" && tag != "feedback" {
285
+ return nil
286
+ }
287
+ attrs := []any{
288
+ "ticket", cw.Item.Parent.Key, "repo", repoName,
289
+ "runID", string(cw.RunID), "nodeVisitID", visit,
290
+ }
291
+ // Workflow attribution comes from the projection; a missing read
292
+ // degrades to the always-known attrs above rather than failing the
293
+ // activity after the comment already landed.
294
+ if a.Runs != nil && a.Runs.DB != nil && cw.RunID != "" {
295
+ if r, err := a.Runs.get(ctx, cw.RunID); err == nil {
296
+ attrs = append(attrs, "workflow", r.Workflow)
297
+ }
298
+ }
299
+ var msg string
300
+ if tag == "summary" {
301
+ msg = "summary written"
302
+ } else {
303
+ msg = "feedback written"
304
+ if cw.Item.Mailbox != nil {
305
+ attrs = append(attrs, "node", cw.Item.Mailbox.Node, "mailbox", cw.Item.Mailbox.Key)
306
+ }
307
+ }
308
+ slog.Info(msg, attrs...)
309
+ return nil
310
+ }
311
+
312
+ // splitMarker splits a "<prefix>:<tag>" marker; returns ("","") when there
313
+ // is no colon.
314
+ func splitMarker(m string) (string, string) {
315
+ i := strings.LastIndex(m, ":")
316
+ if i < 0 {
317
+ return "", ""
318
+ }
319
+ return m[:i], m[i+1:]
320
+ }
321
+
322
+ // CompleteMailbox marks the current node mailbox complete.
323
+ //
324
+ // 9.3 transition logging: one info line per completed mailbox carrying the
325
+ // same ticket/runID/node attrs.
326
+ func (a *Activities) CompleteMailbox(ctx context.Context, w run.Work, mailbox task.Mailbox) error {
327
+ sys, err := a.taskSystem(w.Repo)
328
+ if err != nil {
329
+ return err
330
+ }
331
+ if err := sys.CompleteMailbox(ctx, mailbox); err != nil {
332
+ return err
333
+ }
334
+ slog.Info("mailbox completed",
335
+ "ticket", w.Parent.Key, "runID", string(w.RunID),
336
+ "repo", w.Repo, "workflow", w.Workflow,
337
+ "node", mailbox.Node, "mailbox", mailbox.Key)
338
+ return nil
339
+ }
340
+
341
+ // Projection activities: idempotent read-model updates.
342
+
343
+ func (a *Activities) ProjectionUpdateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
344
+ return a.Runs.updateNode(ctx, id, state, node, visit)
345
+ }
346
+
347
+ func (a *Activities) ProjectionUpdateNodeRuntimeVisit(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) error {
348
+ return a.Runs.updateNodeRuntimeVisit(ctx, id, node, visit)
349
+ }
350
+
351
+ func (a *Activities) ProjectionRecordProcessedReport(ctx context.Context, id run.ID, visit run.NodeVisitID, reportID string) error {
352
+ return a.Runs.recordProcessedReport(ctx, id, visit, reportID)
353
+ }
354
+
355
+ func (a *Activities) ProjectionUpdateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
356
+ if err := a.Runs.updateState(ctx, id, state, lastErr, finished); err != nil {
357
+ return err
358
+ }
359
+ // 9.3 run-lifecycle logging: one info line when a run reaches a
360
+ // terminal state. The projection row carries ticket/repo/workflow so
361
+ // the line is attributable without new plumbing through the workflow.
362
+ if state != run.StateCompleted && state != run.StateCanceled {
363
+ return nil
364
+ }
365
+ r, err := a.Runs.get(ctx, id)
366
+ if err != nil {
367
+ // Projection write succeeded; failure to re-read must not fail
368
+ // the activity. Skip the log line rather than retry forever.
369
+ return nil
370
+ }
371
+ attrs := []any{
372
+ "ticket", r.Ticket.Key, "runID", string(id),
373
+ "repo", r.Repo, "workflow", r.Workflow,
374
+ "state", string(state),
375
+ }
376
+ if r.LastError != "" {
377
+ attrs = append(attrs, "reason", r.LastError)
378
+ }
379
+ msg := "run completed"
380
+ if state == run.StateCanceled {
381
+ msg = "run canceled"
382
+ }
383
+ slog.Info(msg, attrs...)
384
+ return nil
385
+ }
386
+
387
+ func (a *Activities) ProjectionUpdateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
388
+ return a.Runs.updateRetry(ctx, id, status)
389
+ }
390
+
391
+ // MailboxSpecForNode builds the mailbox description for a work node. The
392
+ // description defines the node work: node identity, parent, type, agent,
393
+ // description, and every legal route with its when explanation.
394
+ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflow.Node) task.MailboxSpec {
395
+ var b strings.Builder
396
+ fmt.Fprintf(&b, "Parent Jira ticket: %s\nNode: %s\nType: %s\nAgent: %s\n\nWork:\n%s\n\nRead this subtask's comments for feedback from previous nodes.",
397
+ ticketKey, name, n.Type, n.Agent, n.Description)
398
+ if n.Type == workflow.NodeHITL {
399
+ b.WriteString(`
400
+
401
+ 1. Discuss the task with the human, request the PR link or any missing context, and review the changes. Do not make code changes.
402
+ 2. Resolve questions and requested review updates through normal conversation until the human is satisfied with the review.
403
+ 3. Present the complete report through OpenCode's Question tool with exactly two options: Approve and Reject.
404
+ 4. If approved, output the report verbatim. If rejected, return to step 1.`)
405
+ }
406
+ b.WriteString(`
407
+
408
+ Required report format:
409
+
410
+ STATUS: success | failure
411
+ NEXT STEP: <one valid node name>
412
+
413
+ SUMMARY:
414
+ COMPLETED:
415
+ COMMITS:
416
+ NOT COMPLETED:
417
+ ISSUES DISCOVERED:
418
+ VERIFICATION:
419
+ NOTES:
420
+
421
+ FEEDBACK:
422
+ REASON FOR NEXT STEP:
423
+ REQUIRED ACTIONS:
424
+ RELEVANT CONTEXT:
425
+ EXPECTED RESULT:
426
+
427
+ Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None. NEXT STEP must name exactly one target listed below for your status. When NEXT STEP is end, every FEEDBACK field must be None.`)
428
+ writeRoutes := func(label string, routes []workflow.Route) {
429
+ if len(routes) == 0 {
430
+ return
431
+ }
432
+ fmt.Fprintf(&b, "\n\n%s:", label)
433
+ for _, r := range routes {
434
+ if r.When != "" {
435
+ fmt.Fprintf(&b, "\n- %s — when: %s", r.Target, r.When)
436
+ } else {
437
+ fmt.Fprintf(&b, "\n- %s", r.Target)
438
+ }
439
+ }
440
+ }
441
+ writeRoutes("On success", n.OnSuccess)
442
+ writeRoutes("On failure", n.OnFailure)
443
+ return task.MailboxSpec{
444
+ Node: name,
445
+ Title: ticketKey + ":" + name,
446
+ Description: b.String(),
447
+ TaskConfig: n.TaskConfig,
448
+ }
449
+ }
450
+
451
+ // MailboxSpecs returns one spec per work node, sorted for determinism.
452
+ func MailboxSpecs(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
453
+ names := make([]string, 0, len(wf.Nodes))
454
+ for name, n := range wf.Nodes {
455
+ if n.Type == workflow.NodeAgent || n.Type == workflow.NodeHITL {
456
+ names = append(names, name)
457
+ }
458
+ }
459
+ sort.Strings(names)
460
+ out := make([]task.MailboxSpec, 0, len(names))
461
+ for _, name := range names {
462
+ out = append(out, MailboxSpecForNode(wf, ticketKey, name, wf.Nodes[name]))
463
+ }
464
+ return out
465
+ }
466
+
467
+ // BuildLaunchSpecPrompt points the agent to its parent and isolated mailbox.
468
+ func BuildLaunchSpecPrompt(ticketKey, mailboxKey string) string {
469
+ return fmt.Sprintf("Read parent Jira ticket %s for the original task context.\n\nYour Jira mailbox subtask is %s. Read only its description and comments for your node instructions and feedback.", ticketKey, mailboxKey)
470
+ }
471
+
472
+ func followUpPrompt(mailboxKey string) string {
473
+ return fmt.Sprintf("New feedback was added to the comments section of your mailbox subtask %s. Read it.", mailboxKey)
474
+ }
475
+
476
+ func appendPrompt(prompt, extra string) string {
477
+ if extra == "" {
478
+ return prompt
479
+ }
480
+ return prompt + "\n\n" + extra
481
+ }
482
+
483
+ // mergeTaskConfig overlays node task config onto workflow task config using
484
+ // the shared deterministic merge (maps recursively, scalar/list replace).
485
+ func mergeTaskConfig(wfCfg, nodeCfg map[string]any) map[string]any {
486
+ if len(wfCfg) == 0 && len(nodeCfg) == 0 {
487
+ return nil
488
+ }
489
+ return config.Merge(wfCfg, nodeCfg)
490
+ }