relay-flow 0.1.0-alpha.0 → 0.2.1-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 (118) hide show
  1. package/README.md +160 -143
  2. package/cmd/relay-flow/commands_test.go +968 -0
  3. package/cmd/relay-flow/main.go +877 -175
  4. package/cmd/relay-flow/scenario_test.go +1310 -0
  5. package/cmd/relay-flow/serve.go +610 -0
  6. package/examples/default-story-workflow.yaml +88 -0
  7. package/go.mod +69 -2
  8. package/go.sum +185 -0
  9. package/internal/config/config.go +88 -0
  10. package/internal/config/machine.go +99 -48
  11. package/internal/config/machine_test.go +248 -0
  12. package/internal/config/merge_test.go +118 -0
  13. package/internal/config/writeatomic.go +36 -0
  14. package/internal/config/writeatomic_test.go +98 -0
  15. package/internal/execution/goworkflows/activities.go +490 -0
  16. package/internal/execution/goworkflows/engine.go +520 -0
  17. package/internal/execution/goworkflows/engine_test.go +660 -0
  18. package/internal/execution/goworkflows/fakes_test.go +509 -0
  19. package/internal/execution/goworkflows/interpreter.go +613 -0
  20. package/internal/execution/goworkflows/logging_test.go +154 -0
  21. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  22. package/internal/execution/goworkflows/node_runtime_integration_test.go +133 -0
  23. package/internal/execution/goworkflows/node_runtime_test.go +510 -0
  24. package/internal/execution/goworkflows/projection.go +504 -0
  25. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  26. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  27. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  28. package/internal/harness/contract_test.go +174 -0
  29. package/internal/harness/factory.go +63 -0
  30. package/internal/harness/harness.go +41 -0
  31. package/internal/harness/opencode/opencode.go +166 -0
  32. package/internal/harness/opencode/opencode_test.go +50 -0
  33. package/internal/harness/plugin_selection_test.go +126 -0
  34. package/internal/identity/identity.go +37 -0
  35. package/internal/logging/logging.go +56 -0
  36. package/internal/logging/logging_test.go +116 -0
  37. package/internal/paths/paths.go +69 -0
  38. package/internal/recover/recover.go +115 -0
  39. package/internal/repo/poller.go +186 -0
  40. package/internal/repo/poller_test.go +327 -0
  41. package/internal/repo/repo.go +132 -0
  42. package/internal/repo/service.go +216 -0
  43. package/internal/repo/service_test.go +298 -0
  44. package/internal/retry/retry.go +118 -0
  45. package/internal/router/router.go +84 -0
  46. package/internal/router/router_test.go +231 -0
  47. package/internal/run/manager.go +121 -0
  48. package/internal/run/run.go +140 -0
  49. package/internal/run/run_identity_test.go +52 -0
  50. package/internal/run/run_manager_test.go +286 -0
  51. package/internal/runner/contract_test.go +259 -0
  52. package/internal/runner/factory.go +65 -0
  53. package/internal/runner/orca/orca.go +341 -172
  54. package/internal/runner/orca/orca_test.go +256 -143
  55. package/internal/runner/orca/orcacli/orcacli.go +220 -0
  56. package/internal/runner/orca/orcacli/orcacli_test.go +157 -0
  57. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  58. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +32 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  66. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  67. package/internal/runner/runner.go +54 -64
  68. package/internal/server/api_test.go +300 -0
  69. package/internal/server/client.go +192 -74
  70. package/internal/server/fixture_test.go +248 -0
  71. package/internal/server/server.go +425 -248
  72. package/internal/server/shutdown_test.go +116 -0
  73. package/internal/task/auth_test.go +48 -0
  74. package/internal/task/contract_test.go +225 -0
  75. package/internal/task/factory.go +119 -0
  76. package/internal/task/jira/auth.go +183 -0
  77. package/internal/task/jira/auth_test.go +107 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +254 -0
  80. package/internal/task/jira/helpers_test.go +70 -0
  81. package/internal/task/jira/jira.go +538 -0
  82. package/internal/task/jira/normalize.go +119 -0
  83. package/internal/task/jira/rest/adf.go +128 -0
  84. package/internal/task/jira/rest/client.go +573 -0
  85. package/internal/task/jira/rest/client_test.go +381 -0
  86. package/internal/task/jira/testdata/jira_search_issues.json +120 -0
  87. package/internal/task/jira/transition_defaults_test.go +158 -0
  88. package/internal/task/jira/validation_test.go +94 -0
  89. package/internal/task/task.go +84 -0
  90. package/internal/workflow/report.go +85 -0
  91. package/internal/workflow/report_test.go +259 -0
  92. package/internal/workflow/service.go +142 -0
  93. package/internal/workflow/store.go +136 -0
  94. package/internal/workflow/store_test.go +282 -0
  95. package/internal/workflow/workflow.go +345 -0
  96. package/internal/workflow/workflow_test.go +412 -0
  97. package/package.json +7 -2
  98. package/internal/acli/acli.go +0 -229
  99. package/internal/config/demo_test.go +0 -17
  100. package/internal/config/schema.go +0 -193
  101. package/internal/config/schema_test.go +0 -162
  102. package/internal/daemon/daemon.go +0 -218
  103. package/internal/daemon/daemon_test.go +0 -204
  104. package/internal/discovery/discovery.go +0 -122
  105. package/internal/discovery/discovery_test.go +0 -62
  106. package/internal/opencode/opencode.go +0 -26
  107. package/internal/orcacli/orcacli.go +0 -264
  108. package/internal/runner/orca/README.md +0 -64
  109. package/internal/runner/runner_test.go +0 -64
  110. package/internal/server/server_test.go +0 -195
  111. package/internal/tasks/jira/README.md +0 -69
  112. package/internal/tasks/jira/component_test.go +0 -16
  113. package/internal/tasks/jira/decode.go +0 -24
  114. package/internal/tasks/jira/jira.go +0 -231
  115. package/internal/tasks/jira/jira_test.go +0 -259
  116. package/internal/tasks/jira/jql_test.go +0 -16
  117. package/internal/tasks/tasks.go +0 -90
  118. package/internal/tasks/tasks_test.go +0 -91
@@ -0,0 +1,490 @@
1
+ package goworkflows
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "log/slog"
7
+ "sort"
8
+ "strings"
9
+ "time"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/config"
12
+ "github.com/rajpopat27/relay-flow/internal/harness"
13
+ "github.com/rajpopat27/relay-flow/internal/repo"
14
+ "github.com/rajpopat27/relay-flow/internal/run"
15
+ "github.com/rajpopat27/relay-flow/internal/runner"
16
+ "github.com/rajpopat27/relay-flow/internal/task"
17
+ "github.com/rajpopat27/relay-flow/internal/workflow"
18
+ )
19
+
20
+ // Activities holds the replaceable dependencies shared by every durable
21
+ // activity. One Activities value is registered with the activity worker.
22
+ type Activities struct {
23
+ Repos *repo.Registry
24
+ Runner runner.Runner
25
+ Harness harness.Harness
26
+ TaskSystem string
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) SetEnvironmentStatus(ctx context.Context, w run.Work, repoPath, status string) error {
101
+ spec := a.runSpec(w)
102
+ spec.RepoPath = repoPath
103
+ env, err := a.Runner.EnsureEnvironment(ctx, spec)
104
+ if err != nil {
105
+ return err
106
+ }
107
+ return a.Runner.SetEnvironmentStatus(ctx, env, status)
108
+ }
109
+
110
+ func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
111
+ return a.Runs.loadNodeRuntime(ctx, id, node)
112
+ }
113
+
114
+ // EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
115
+ // path. A live terminal is rebound to the new visit; otherwise EnsureTerminal
116
+ // creates a replacement and its direct ID is persisted immediately.
117
+ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) error {
118
+ a.Runs.runtimeMu.Lock()
119
+ defer a.Runs.runtimeMu.Unlock()
120
+ slog.Info("node entered",
121
+ "ticket", nw.Parent.Key, "runID", string(nw.RunID),
122
+ "repo", nw.Repo, "workflow", nw.Workflow,
123
+ "node", nw.Node, "nodeVisitID", string(nw.NodeVisitID),
124
+ "nodeType", string(spec.NodeType), "agent", spec.Agent)
125
+ revisit := rt.NodeVisitID != "" && rt.NodeVisitID != spec.NodeVisitID
126
+ current, err := a.Runs.nodeRuntimeVisitIsCurrent(ctx, nw.RunID, nw.Node, nw.NodeVisitID)
127
+ if err != nil {
128
+ return err
129
+ }
130
+ if !current {
131
+ return fmt.Errorf("node runtime %s/%s visit %s is stale", nw.RunID, nw.Node, nw.NodeVisitID)
132
+ }
133
+ currentRuntime, err := a.Runs.loadNodeRuntime(ctx, nw.RunID, nw.Node)
134
+ if err != nil {
135
+ return err
136
+ }
137
+ rs := a.runSpec(nw.Work)
138
+ rs.RepoPath = repoPath
139
+ env, err := a.Runner.EnsureEnvironment(ctx, rs)
140
+ if err != nil {
141
+ return err
142
+ }
143
+ status := runner.WorkspaceStatusInProgress
144
+ if spec.NodeType == workflow.NodeHITL {
145
+ status = runner.WorkspaceStatusInReview
146
+ }
147
+ if err := a.Runner.SetEnvironmentStatus(ctx, env, status); err != nil {
148
+ return err
149
+ }
150
+ hadRuntime := currentRuntime.TerminalID != "" || currentRuntime.SessionID != ""
151
+ // IDs come from the guarded current row; the activity input's prior visit
152
+ // is used only to decide whether a live process needs rebinding.
153
+ rt.TerminalID = currentRuntime.TerminalID
154
+ rt.SessionID = currentRuntime.SessionID
155
+ spec.ResumeID = rt.SessionID
156
+ // Custom instructions belong to a node entry, not same-visit recovery.
157
+ if !hadRuntime || revisit {
158
+ spec.Prompt = appendPrompt(spec.Prompt, spec.NudgePrompt)
159
+ }
160
+ cmd, err := a.Harness.BuildCommand(spec)
161
+ if err != nil {
162
+ return err
163
+ }
164
+ stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
165
+ terminal, err := a.Runner.EnsureTerminal(ctx, env, stored, spec.Title, cmd)
166
+ if err != nil {
167
+ return err
168
+ }
169
+ // Persist a newly created/replacement handle before any later external
170
+ // effect. Runtime session registration may update SessionID independently.
171
+ if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
172
+ terminal.ID, rt.SessionID, rt.SessionID); err != nil {
173
+ // The visit changed after terminal creation. Close the terminal whose
174
+ // binding was rejected so stale work cannot leak or report.
175
+ if terminal.ID != rt.TerminalID {
176
+ _ = a.Runner.CloseTerminal(ctx, terminal)
177
+ }
178
+ return err
179
+ }
180
+ if terminal.ID != rt.TerminalID || !revisit {
181
+ // A newly created terminal receives its prompt in the launch command;
182
+ // same-visit reuse leaves the running turn untouched.
183
+ return nil
184
+ }
185
+ prompt := followUpPrompt(nw.Mailbox.Key)
186
+ if spec.NudgePrompt != "" {
187
+ prompt += "\n\n" + spec.NudgePrompt
188
+ }
189
+ if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
190
+ return nil
191
+ }
192
+ // Direct use failed. Close the known live terminal before replacing it so
193
+ // a second agent process cannot be left running.
194
+ if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
195
+ return err
196
+ }
197
+ replacement, err := a.Runner.EnsureTerminal(ctx, env, terminal, spec.Title, cmd)
198
+ if err != nil {
199
+ return err
200
+ }
201
+ if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
202
+ replacement.ID, rt.SessionID, rt.SessionID); err != nil {
203
+ _ = a.Runner.CloseTerminal(ctx, replacement)
204
+ return err
205
+ }
206
+ return nil
207
+ }
208
+
209
+ // CloseTerminals closes run-owned agent terminals, preserving the
210
+ // environment/workspace.
211
+ func (a *Activities) CloseTerminals(ctx context.Context, w run.Work, repoPath string) error {
212
+ spec := a.runSpec(w)
213
+ spec.RepoPath = repoPath
214
+ return a.Runner.CloseTerminals(ctx, spec)
215
+ }
216
+
217
+ func (a *Activities) CheckpointNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, policy run.RuntimePolicy) error {
218
+ a.Runs.runtimeMu.Lock()
219
+ defer a.Runs.runtimeMu.Unlock()
220
+ rt, err := a.Runs.loadNodeRuntime(ctx, nw.RunID, nw.Node)
221
+ if err != nil {
222
+ return err
223
+ }
224
+ if !policy.KeepTerminalsAlive && rt.TerminalID != "" {
225
+ if err := a.Runner.CloseTerminal(ctx, runner.Terminal{ID: rt.TerminalID, Title: nw.Parent.Key + ":" + nw.Node}); err != nil {
226
+ return err
227
+ }
228
+ }
229
+ return a.Runs.clearNodeRuntime(ctx, nw.RunID, nw.Node,
230
+ !policy.KeepTerminalsAlive, !policy.KeepSessionsAlive)
231
+ }
232
+
233
+ func (a *Activities) FinalizeNodeRuntimes(ctx context.Context, w run.Work, repoPath string, policy run.RuntimePolicy) error {
234
+ a.Runs.runtimeMu.Lock()
235
+ defer a.Runs.runtimeMu.Unlock()
236
+ runtimes, err := a.Runs.listNodeRuntimes(ctx, w.RunID)
237
+ if err != nil {
238
+ return err
239
+ }
240
+ if !policy.KeepTerminalsAlive {
241
+ for _, rt := range runtimes {
242
+ if rt.TerminalID == "" {
243
+ continue
244
+ }
245
+ if err := a.Runner.CloseTerminal(ctx, runner.Terminal{ID: rt.TerminalID, Title: w.Parent.Key + ":" + rt.Node}); err != nil {
246
+ return err
247
+ }
248
+ }
249
+ }
250
+ for _, rt := range runtimes {
251
+ if err := a.Runs.clearNodeRuntime(ctx, w.RunID, rt.Node,
252
+ !policy.KeepTerminalsAlive, !policy.KeepSessionsAlive); err != nil {
253
+ return err
254
+ }
255
+ }
256
+ return nil
257
+ }
258
+
259
+ // CleanupRun removes all runner-owned run resources at end.
260
+ func (a *Activities) CleanupRun(ctx context.Context, w run.Work, repoPath string) error {
261
+ spec := a.runSpec(w)
262
+ spec.RepoPath = repoPath
263
+ return a.Runner.CleanupRun(ctx, spec)
264
+ }
265
+
266
+ // Comment writes a marked comment to the target on the run's repo.
267
+ //
268
+ // 9.3 transition logging: the interpreter uses markers of the form
269
+ // "<nodeVisitID>:summary" for the current node summary and
270
+ // "<nodeVisitID>:feedback" for the selected-next feedback, so this
271
+ // activity emits one info line per effect with the same ticket/runID/node
272
+ // attrs as the rest of the run. Cancellation markers and other comments
273
+ // still pass through silently (no transition effect).
274
+ func (a *Activities) Comment(ctx context.Context, repoName string, cw run.CommentWork) error {
275
+ sys, err := a.taskSystem(repoName)
276
+ if err != nil {
277
+ return err
278
+ }
279
+ if err := sys.Comment(ctx, cw.Item, cw.Body, cw.Marker); err != nil {
280
+ return err
281
+ }
282
+ // Log AFTER the write succeeds so the line is a true effect record.
283
+ // marker is "<nodeVisitID>:summary" | "<nodeVisitID>:feedback" |
284
+ // "<runID>:cancellation". Only the first two are transition effects.
285
+ visit, tag := splitMarker(cw.Marker)
286
+ if tag != "summary" && tag != "feedback" {
287
+ return nil
288
+ }
289
+ attrs := []any{
290
+ "ticket", cw.Item.Parent.Key, "repo", repoName,
291
+ "runID", string(cw.RunID), "nodeVisitID", visit,
292
+ }
293
+ // Workflow attribution comes from the projection; a missing read
294
+ // degrades to the always-known attrs above rather than failing the
295
+ // activity after the comment already landed.
296
+ if a.Runs != nil && a.Runs.DB != nil && cw.RunID != "" {
297
+ if r, err := a.Runs.get(ctx, cw.RunID); err == nil {
298
+ attrs = append(attrs, "workflow", r.Workflow)
299
+ }
300
+ }
301
+ var msg string
302
+ if tag == "summary" {
303
+ msg = "summary written"
304
+ } else {
305
+ msg = "feedback written"
306
+ if cw.Item.Mailbox != nil {
307
+ attrs = append(attrs, "node", cw.Item.Mailbox.Node, "mailbox", cw.Item.Mailbox.Key)
308
+ }
309
+ }
310
+ slog.Info(msg, attrs...)
311
+ return nil
312
+ }
313
+
314
+ // splitMarker splits a "<prefix>:<tag>" marker; returns ("","") when there
315
+ // is no colon.
316
+ func splitMarker(m string) (string, string) {
317
+ i := strings.LastIndex(m, ":")
318
+ if i < 0 {
319
+ return "", ""
320
+ }
321
+ return m[:i], m[i+1:]
322
+ }
323
+
324
+ // CompleteMailbox marks the current node mailbox complete.
325
+ //
326
+ // 9.3 transition logging: one info line per completed mailbox carrying the
327
+ // same ticket/runID/node attrs.
328
+ func (a *Activities) CompleteMailbox(ctx context.Context, w run.Work, mailbox task.Mailbox) error {
329
+ sys, err := a.taskSystem(w.Repo)
330
+ if err != nil {
331
+ return err
332
+ }
333
+ if err := sys.CompleteMailbox(ctx, mailbox); err != nil {
334
+ return err
335
+ }
336
+ slog.Info("mailbox completed",
337
+ "ticket", w.Parent.Key, "runID", string(w.RunID),
338
+ "repo", w.Repo, "workflow", w.Workflow,
339
+ "node", mailbox.Node, "mailbox", mailbox.Key)
340
+ return nil
341
+ }
342
+
343
+ // Projection activities: idempotent read-model updates.
344
+
345
+ func (a *Activities) ProjectionUpdateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
346
+ return a.Runs.updateNode(ctx, id, state, node, visit)
347
+ }
348
+
349
+ func (a *Activities) ProjectionUpdateNodeRuntimeVisit(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) error {
350
+ return a.Runs.updateNodeRuntimeVisit(ctx, id, node, visit)
351
+ }
352
+
353
+ func (a *Activities) ProjectionRecordProcessedReport(ctx context.Context, id run.ID, visit run.NodeVisitID, reportID string) error {
354
+ return a.Runs.recordProcessedReport(ctx, id, visit, reportID)
355
+ }
356
+
357
+ func (a *Activities) ProjectionUpdateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
358
+ if err := a.Runs.updateState(ctx, id, state, lastErr, finished); err != nil {
359
+ return err
360
+ }
361
+ // 9.3 run-lifecycle logging: one info line when a run reaches a
362
+ // terminal state. The projection row carries ticket/repo/workflow so
363
+ // the line is attributable without new plumbing through the workflow.
364
+ if state != run.StateCompleted && state != run.StateCanceled {
365
+ return nil
366
+ }
367
+ r, err := a.Runs.get(ctx, id)
368
+ if err != nil {
369
+ // Projection write succeeded; failure to re-read must not fail
370
+ // the activity. Skip the log line rather than retry forever.
371
+ return nil
372
+ }
373
+ attrs := []any{
374
+ "ticket", r.Ticket.Key, "runID", string(id),
375
+ "repo", r.Repo, "workflow", r.Workflow,
376
+ "state", string(state),
377
+ }
378
+ if r.LastError != "" {
379
+ attrs = append(attrs, "reason", r.LastError)
380
+ }
381
+ msg := "run completed"
382
+ if state == run.StateCanceled {
383
+ msg = "run canceled"
384
+ }
385
+ slog.Info(msg, attrs...)
386
+ return nil
387
+ }
388
+
389
+ func (a *Activities) ProjectionUpdateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
390
+ return a.Runs.updateRetry(ctx, id, status)
391
+ }
392
+
393
+ // MailboxSpecForNode builds the mailbox description for a work node. The
394
+ // description defines the node work: node identity, parent, type, agent,
395
+ // description, and every legal route with its when explanation.
396
+ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflow.Node) task.MailboxSpec {
397
+ var b strings.Builder
398
+ fmt.Fprintf(&b, "Parent ticket: %s\nNode: %s\nType: %s\nAgent: %s\n\nWork:\n%s\n\nRead this subtask's comments for feedback from previous nodes.",
399
+ ticketKey, name, n.Type, n.Agent, n.Description)
400
+ if n.Type == workflow.NodeHITL {
401
+ b.WriteString(`
402
+
403
+ 1. Discuss the task with the human, request the PR link or any missing context, and review the changes. Do not make code changes.
404
+ 2. Resolve questions and requested review updates through normal conversation until the human is satisfied with the review.`)
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(taskSystem, ticketKey, mailboxKey string) string {
469
+ return fmt.Sprintf("Task system: %s\nUse the %s tools to read the parent ticket %s.\n\nYour mailbox is %s. Read its description and comments for node instructions and feedback.", taskSystem, taskSystem, 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
+ }