relay-flow 0.2.4-alpha → 0.2.5-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 (45) hide show
  1. package/README.md +20 -15
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +94 -13
  4. package/cmd/relay-flow/scenario_test.go +19 -2
  5. package/cmd/relay-flow/serve.go +98 -19
  6. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  7. package/cmd/relay-flow/temporal_init.go +170 -0
  8. package/cmd/relay-flow/temporal_init_test.go +217 -0
  9. package/cmd/relay-flow/temporal_report_test.go +733 -0
  10. package/examples/config-reference.yaml +2 -2
  11. package/examples/minimal-beads-task-workflow.yaml +2 -1
  12. package/examples/workflow-reference.yaml +2 -1
  13. package/go.mod +37 -16
  14. package/go.sum +129 -61
  15. package/internal/config/machine.go +33 -1
  16. package/internal/config/machine_test.go +76 -0
  17. package/internal/execution/goworkflows/engine.go +13 -38
  18. package/internal/execution/goworkflows/projection.go +47 -464
  19. package/internal/execution/projection/projection.go +867 -0
  20. package/internal/execution/projection/projection_test.go +347 -0
  21. package/internal/execution/temporal/activities.go +567 -0
  22. package/internal/execution/temporal/engine.go +384 -0
  23. package/internal/execution/temporal/engine_test.go +277 -0
  24. package/internal/execution/temporal/interpreter.go +736 -0
  25. package/internal/execution/temporal/operations.go +455 -0
  26. package/internal/execution/temporal/operations_test.go +101 -0
  27. package/internal/execution/temporal/recovery.go +194 -0
  28. package/internal/execution/temporal/recovery_runtime.go +41 -0
  29. package/internal/execution/temporal/recovery_test.go +102 -0
  30. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  31. package/internal/execution/temporal/spike_test.go +934 -0
  32. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  33. package/internal/harness/opencode/opencode.go +3 -1
  34. package/internal/harness/opencode/opencode_test.go +1 -1
  35. package/internal/harness/opencode/repo_setup.go +1 -1
  36. package/internal/harness/pi/pi.go +49 -46
  37. package/internal/harness/pi/pi_test.go +26 -10
  38. package/internal/harness/pi/prompt_test.go +30 -1
  39. package/internal/harness/pi/validation_test.go +27 -51
  40. package/internal/runner/herdr/herdr.go +14 -0
  41. package/internal/runner/herdr/herdr_test.go +20 -0
  42. package/internal/runner/orca/orca.go +33 -0
  43. package/internal/runner/orca/orca_test.go +33 -4
  44. package/internal/runner/runner.go +8 -0
  45. package/package.json +1 -1
@@ -0,0 +1,567 @@
1
+ package temporal
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "log/slog"
7
+ "sort"
8
+ "strings"
9
+ "sync"
10
+ "time"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/config"
13
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
14
+ "github.com/rajpopat27/relay-flow/internal/harness"
15
+ "github.com/rajpopat27/relay-flow/internal/repo"
16
+ "github.com/rajpopat27/relay-flow/internal/run"
17
+ "github.com/rajpopat27/relay-flow/internal/runner"
18
+ "github.com/rajpopat27/relay-flow/internal/task"
19
+ "github.com/rajpopat27/relay-flow/internal/workflow"
20
+ )
21
+
22
+ // Activities holds the replaceable dependencies shared by every durable
23
+ // activity. One Activities value is registered with the activity worker.
24
+ type NodeRuntime = projection.NodeRuntime
25
+
26
+ type Activities struct {
27
+ Repos *repo.Registry
28
+ Runner runner.Runner
29
+ Harness harness.Harness
30
+ TaskSystem string
31
+ Runs *projection.RunProjection
32
+ runtimeMu sync.Mutex
33
+ }
34
+
35
+ func (a *Activities) taskSystem(repoName string) (task.System, error) {
36
+ rp, ok := a.Repos.Get(repoName)
37
+ if !ok {
38
+ return nil, fmt.Errorf("repo %q is not registered", repoName)
39
+ }
40
+ return rp.TaskSystem, nil
41
+ }
42
+
43
+ func (a *Activities) runSpec(w run.Work) runner.RunSpec {
44
+ return runner.RunSpec{
45
+ RunID: w.RunID,
46
+ RepoName: w.Repo,
47
+ RepoPath: "",
48
+ TicketKey: w.Parent.Key,
49
+ }
50
+ }
51
+
52
+ // EnsureMailboxes ensures one mailbox per work node and returns the
53
+ // node-to-mailbox map.
54
+ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
55
+ sys, err := a.taskSystem(w.Repo)
56
+ if err != nil {
57
+ return nil, err
58
+ }
59
+ for i := range specs {
60
+ data := specs[i].TextData
61
+ data.RunID = string(w.RunID)
62
+ data.Repo = w.Repo
63
+ custom, err := sys.RenderText(task.TextMailboxDescription, data)
64
+ if err != nil {
65
+ return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
66
+ }
67
+ specs[i].Description = appendText(custom, specs[i].Description)
68
+ }
69
+ return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
70
+ }
71
+
72
+ // PrepareRestart reopens mailbox state through the optional task-system
73
+ // capability, then closes any surviving run-owned terminals while preserving
74
+ // the ticket worktree. Both operations are idempotent/retryable and remain
75
+ // behind their respective task and runner interfaces.
76
+ func (a *Activities) PrepareRestart(ctx context.Context, w run.Work, repoPath string, mailboxes []task.Mailbox) error {
77
+ sys, err := a.taskSystem(w.Repo)
78
+ if err != nil {
79
+ return err
80
+ }
81
+ if preparer, ok := sys.(task.RestartPreparer); ok {
82
+ if err := preparer.PrepareRestart(ctx, w.Parent, mailboxes); err != nil {
83
+ return err
84
+ }
85
+ }
86
+ spec := a.runSpec(w)
87
+ spec.RepoPath = repoPath
88
+ return a.Runner.CloseTerminals(ctx, spec)
89
+ }
90
+
91
+ // ValidateAgents validates every referenced agent on the repo.
92
+ func (a *Activities) ValidateAgents(ctx context.Context, repoPath string, agents []string) error {
93
+ for _, agent := range agents {
94
+ if err := a.Harness.ValidateAgent(ctx, repoPath, agent); err != nil {
95
+ return fmt.Errorf("validate agent %q: %w", agent, err)
96
+ }
97
+ }
98
+ return nil
99
+ }
100
+
101
+ // ApplyTaskConfig applies adapter-owned task config to the parent and
102
+ // optional mailbox target.
103
+ func (a *Activities) ApplyTaskConfig(ctx context.Context, w run.Work, node string, mailbox *task.Mailbox, cfg map[string]any) error {
104
+ sys, err := a.taskSystem(w.Repo)
105
+ if err != nil {
106
+ return err
107
+ }
108
+ // Adapters with lifecycle-dependent taskConfig defaults (e.g. Jira
109
+ // transitionTo) expose them via task.LifecycleDefaults; merge them as
110
+ // the lowest layer under the effective node config so explicit values
111
+ // win. Core never learns adapter vocabulary.
112
+ if d, ok := sys.(task.LifecycleDefaults); ok {
113
+ var defaults config.RawValues
114
+ switch {
115
+ case node == "start":
116
+ defaults = d.StartDefaults()
117
+ case node == "end":
118
+ defaults = d.EndDefaults()
119
+ default:
120
+ defaults = d.WorkDefaults()
121
+ }
122
+ cfg = map[string]any(config.Merge(defaults, cfg))
123
+ }
124
+ return sys.ApplyTaskConfig(ctx, task.Target{Parent: w.Parent, Mailbox: mailbox}, cfg)
125
+ }
126
+
127
+ // EnsureEnvironment ensures the ticket-scoped runner environment.
128
+ func (a *Activities) EnsureEnvironment(ctx context.Context, w run.Work, repoPath string) (runner.Environment, error) {
129
+ spec := a.runSpec(w)
130
+ spec.RepoPath = repoPath
131
+ return a.Runner.EnsureEnvironment(ctx, spec)
132
+ }
133
+
134
+ func (a *Activities) SetEnvironmentStatus(ctx context.Context, w run.Work, repoPath, status string) error {
135
+ spec := a.runSpec(w)
136
+ spec.RepoPath = repoPath
137
+ env, err := a.Runner.EnsureEnvironment(ctx, spec)
138
+ if err != nil {
139
+ return err
140
+ }
141
+ return a.Runner.SetEnvironmentStatus(ctx, env, status)
142
+ }
143
+
144
+ func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
145
+ return a.Runs.LoadNodeRuntime(ctx, id, node)
146
+ }
147
+
148
+ // EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
149
+ // path. A live terminal is rebound to the new visit; otherwise EnsureTerminal
150
+ // creates a replacement and its direct ID is persisted immediately.
151
+ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) (NodeRuntime, error) {
152
+ a.runtimeMu.Lock()
153
+ defer a.runtimeMu.Unlock()
154
+ slog.Info("node entered",
155
+ "ticket", nw.Parent.Key, "runID", string(nw.RunID),
156
+ "repo", nw.Repo, "workflow", nw.Workflow,
157
+ "node", nw.Node, "nodeVisitID", string(nw.NodeVisitID),
158
+ "nodeType", string(spec.NodeType), "agent", spec.Agent)
159
+ revisit := rt.NodeVisitID != "" && rt.NodeVisitID != spec.NodeVisitID
160
+ current, err := a.Runs.NodeRuntimeVisitIsCurrent(ctx, nw.RunID, nw.Node, nw.NodeVisitID)
161
+ if err != nil {
162
+ return NodeRuntime{}, err
163
+ }
164
+ if !current {
165
+ return NodeRuntime{}, fmt.Errorf("node runtime %s/%s visit %s is stale", nw.RunID, nw.Node, nw.NodeVisitID)
166
+ }
167
+ currentRuntime, err := a.Runs.LoadNodeRuntime(ctx, nw.RunID, nw.Node)
168
+ if err != nil {
169
+ return NodeRuntime{}, err
170
+ }
171
+ rs := a.runSpec(nw.Work)
172
+ rs.RepoPath = repoPath
173
+ env, err := a.Runner.EnsureEnvironment(ctx, rs)
174
+ if err != nil {
175
+ return NodeRuntime{}, err
176
+ }
177
+ if spec.PromptData.TaskSystem == "" {
178
+ spec.PromptData.TaskSystem = a.TaskSystem
179
+ }
180
+ status := runner.WorkspaceStatusInProgress
181
+ if spec.NodeType == workflow.NodeHITL {
182
+ status = runner.WorkspaceStatusInReview
183
+ }
184
+ if err := a.Runner.SetEnvironmentStatus(ctx, env, status); err != nil {
185
+ return NodeRuntime{}, err
186
+ }
187
+ // IDs come from the guarded current row; the activity input's prior visit
188
+ // is used only to decide whether a live process needs rebinding.
189
+ rt.TerminalID = currentRuntime.TerminalID
190
+ rt.SessionID = currentRuntime.SessionID
191
+ spec.ResumeID = rt.SessionID
192
+ stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
193
+ terminal, live, err := a.Runner.FindTerminal(ctx, stored)
194
+ if err != nil {
195
+ return NodeRuntime{}, err
196
+ }
197
+ if live {
198
+ if err := a.Runs.ReplaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
199
+ terminal.ID, rt.SessionID, rt.SessionID); err != nil {
200
+ return NodeRuntime{}, err
201
+ }
202
+ if !revisit {
203
+ // Same-visit retry/restart is silent: do not render, build, or send.
204
+ return rt, nil
205
+ }
206
+ prompt, err := a.Harness.RenderPrompt(harness.PromptFeedback, spec.PromptData, spec.NudgePrompt)
207
+ if err != nil {
208
+ return NodeRuntime{}, err
209
+ }
210
+ if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
211
+ return rt, nil
212
+ }
213
+ // Direct use failed. Close the known live terminal before replacing it
214
+ // so a second agent process cannot be left running.
215
+ if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
216
+ return NodeRuntime{}, err
217
+ }
218
+ }
219
+
220
+ // An initial or replacement terminal resumes the stored session and
221
+ // receives the rendered initial prompt. Same-visit replacements omit the
222
+ // node nudge; a new visit includes it.
223
+ nudge := ""
224
+ if rt.NodeVisitID == "" || revisit {
225
+ nudge = spec.NudgePrompt
226
+ }
227
+ spec.Prompt, err = a.Harness.RenderPrompt(harness.PromptInitial, spec.PromptData, nudge)
228
+ if err != nil {
229
+ return NodeRuntime{}, err
230
+ }
231
+ cmd, err := a.Harness.BuildCommand(spec)
232
+ if err != nil {
233
+ return NodeRuntime{}, err
234
+ }
235
+ replacement, err := a.Runner.EnsureTerminal(ctx, env, stored, spec.Title, cmd)
236
+ if err != nil {
237
+ return NodeRuntime{}, err
238
+ }
239
+ // Persist a newly created/replacement handle before any later external
240
+ // effect. Runtime session registration may update SessionID independently.
241
+ if err := a.Runs.ReplaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
242
+ replacement.ID, rt.SessionID, rt.SessionID); err != nil {
243
+ if replacement.ID != rt.TerminalID {
244
+ _ = a.Runner.CloseTerminal(ctx, replacement)
245
+ }
246
+ return NodeRuntime{}, err
247
+ }
248
+ rt.TerminalID = replacement.ID
249
+ return rt, nil
250
+ }
251
+
252
+ // CloseTerminals closes run-owned agent terminals, preserving the
253
+ // environment/workspace.
254
+ func (a *Activities) CloseTerminals(ctx context.Context, w run.Work, repoPath string) error {
255
+ spec := a.runSpec(w)
256
+ spec.RepoPath = repoPath
257
+ return a.Runner.CloseTerminals(ctx, spec)
258
+ }
259
+
260
+ func (a *Activities) CheckpointNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, policy run.RuntimePolicy) error {
261
+ a.runtimeMu.Lock()
262
+ defer a.runtimeMu.Unlock()
263
+ rt, err := a.Runs.LoadNodeRuntime(ctx, nw.RunID, nw.Node)
264
+ if err != nil {
265
+ return err
266
+ }
267
+ if !policy.KeepTerminalsAlive && rt.TerminalID != "" {
268
+ if err := a.Runner.CloseTerminal(ctx, runner.Terminal{ID: rt.TerminalID, Title: nw.Parent.Key + ":" + nw.Node}); err != nil {
269
+ return err
270
+ }
271
+ }
272
+ return a.Runs.ClearNodeRuntime(ctx, nw.RunID, nw.Node,
273
+ !policy.KeepTerminalsAlive, !policy.KeepSessionsAlive)
274
+ }
275
+
276
+ func (a *Activities) FinalizeNodeRuntimes(ctx context.Context, w run.Work, repoPath string, policy run.RuntimePolicy) error {
277
+ a.runtimeMu.Lock()
278
+ defer a.runtimeMu.Unlock()
279
+ runtimes, err := a.Runs.ListNodeRuntimes(ctx, w.RunID)
280
+ if err != nil {
281
+ return err
282
+ }
283
+ if !policy.KeepTerminalsAlive {
284
+ for _, rt := range runtimes {
285
+ if rt.TerminalID == "" {
286
+ continue
287
+ }
288
+ if err := a.Runner.CloseTerminal(ctx, runner.Terminal{ID: rt.TerminalID, Title: w.Parent.Key + ":" + rt.Node}); err != nil {
289
+ return err
290
+ }
291
+ }
292
+ }
293
+ for _, rt := range runtimes {
294
+ if err := a.Runs.ClearNodeRuntime(ctx, w.RunID, rt.Node,
295
+ !policy.KeepTerminalsAlive, !policy.KeepSessionsAlive); err != nil {
296
+ return err
297
+ }
298
+ }
299
+ return nil
300
+ }
301
+
302
+ // CleanupRun removes all runner-owned run resources at end.
303
+ func (a *Activities) CleanupRun(ctx context.Context, w run.Work, repoPath string) error {
304
+ spec := a.runSpec(w)
305
+ spec.RepoPath = repoPath
306
+ return a.Runner.CleanupRun(ctx, spec)
307
+ }
308
+
309
+ // Comment writes a marked comment to the target on the run's repo.
310
+ //
311
+ // 9.3 transition logging: the interpreter uses markers of the form
312
+ // "<nodeVisitID>:summary" for the current node summary and
313
+ // "<nodeVisitID>:feedback" for the selected-next feedback, so this
314
+ // activity emits one info line per effect with the same ticket/runID/node
315
+ // attrs as the rest of the run. Cancellation markers and other comments
316
+ // still pass through silently (no transition effect).
317
+ func (a *Activities) Comment(ctx context.Context, repoName string, cw run.CommentWork) error {
318
+ sys, err := a.taskSystem(repoName)
319
+ if err != nil {
320
+ return err
321
+ }
322
+ body := cw.Body
323
+ if cw.TextKind != "" {
324
+ body, err = sys.RenderText(cw.TextKind, cw.TextData)
325
+ if err != nil {
326
+ return fmt.Errorf("render %s: %w", cw.TextKind, err)
327
+ }
328
+ }
329
+ if err := sys.Comment(ctx, cw.Item, body, cw.Marker); err != nil {
330
+ return err
331
+ }
332
+ // Log AFTER the write succeeds so the line is a true effect record.
333
+ // marker is "<nodeVisitID>:summary" | "<nodeVisitID>:feedback" |
334
+ // "<runID>:cancellation". Only the first two are transition effects.
335
+ visit, tag := splitMarker(cw.Marker)
336
+ if tag != "summary" && tag != "feedback" {
337
+ return nil
338
+ }
339
+ attrs := []any{
340
+ "ticket", cw.Item.Parent.Key, "repo", repoName,
341
+ "runID", string(cw.RunID), "nodeVisitID", visit,
342
+ }
343
+ // Workflow attribution comes from the projection; a missing read
344
+ // degrades to the always-known attrs above rather than failing the
345
+ // activity after the comment already landed.
346
+ if a.Runs != nil && a.Runs.DB != nil && cw.RunID != "" {
347
+ if r, err := a.Runs.Get(ctx, cw.RunID); err == nil {
348
+ attrs = append(attrs, "workflow", r.Workflow)
349
+ }
350
+ }
351
+ var msg string
352
+ if tag == "summary" {
353
+ msg = "summary written"
354
+ } else {
355
+ msg = "feedback written"
356
+ if cw.Item.Mailbox != nil {
357
+ attrs = append(attrs, "node", cw.Item.Mailbox.Node, "mailbox", cw.Item.Mailbox.Key)
358
+ }
359
+ }
360
+ slog.Info(msg, attrs...)
361
+ return nil
362
+ }
363
+
364
+ // splitMarker splits a "<prefix>:<tag>" marker; returns ("","") when there
365
+ // is no colon.
366
+ func splitMarker(m string) (string, string) {
367
+ i := strings.LastIndex(m, ":")
368
+ if i < 0 {
369
+ return "", ""
370
+ }
371
+ return m[:i], m[i+1:]
372
+ }
373
+
374
+ // CompleteMailbox marks the current node mailbox complete.
375
+ //
376
+ // 9.3 transition logging: one info line per completed mailbox carrying the
377
+ // same ticket/runID/node attrs.
378
+ func (a *Activities) CompleteMailbox(ctx context.Context, w run.Work, mailbox task.Mailbox) error {
379
+ sys, err := a.taskSystem(w.Repo)
380
+ if err != nil {
381
+ return err
382
+ }
383
+ if err := sys.CompleteMailbox(ctx, mailbox); err != nil {
384
+ return err
385
+ }
386
+ slog.Info("mailbox completed",
387
+ "ticket", w.Parent.Key, "runID", string(w.RunID),
388
+ "repo", w.Repo, "workflow", w.Workflow,
389
+ "node", mailbox.Node, "mailbox", mailbox.Key)
390
+ return nil
391
+ }
392
+
393
+ // Projection activities: idempotent read-model updates.
394
+
395
+ func (a *Activities) ProjectionUpdateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
396
+ return a.Runs.UpdateNode(ctx, id, state, node, visit)
397
+ }
398
+
399
+ func (a *Activities) ProjectionUpdateNodeRuntimeVisit(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) error {
400
+ return a.Runs.UpdateNodeRuntimeVisit(ctx, id, node, visit)
401
+ }
402
+
403
+ func (a *Activities) ProjectionRecordProcessedReport(ctx context.Context, id run.ID, visit run.NodeVisitID, reportID string) error {
404
+ return a.Runs.RecordProcessedReport(ctx, id, visit, reportID)
405
+ }
406
+
407
+ func (a *Activities) ProjectionUpdateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
408
+ if err := a.Runs.UpdateState(ctx, id, state, lastErr, finished); err != nil {
409
+ return err
410
+ }
411
+ // 9.3 run-lifecycle logging: one info line when a run reaches a
412
+ // terminal state. The projection row carries ticket/repo/workflow so
413
+ // the line is attributable without new plumbing through the workflow.
414
+ if state != run.StateCompleted && state != run.StateCanceled {
415
+ return nil
416
+ }
417
+ r, err := a.Runs.Get(ctx, id)
418
+ if err != nil {
419
+ // Projection write succeeded; failure to re-read must not fail
420
+ // the activity. Skip the log line rather than retry forever.
421
+ return nil
422
+ }
423
+ attrs := []any{
424
+ "ticket", r.Ticket.Key, "runID", string(id),
425
+ "repo", r.Repo, "workflow", r.Workflow,
426
+ "state", string(state),
427
+ }
428
+ if r.LastError != "" {
429
+ attrs = append(attrs, "reason", r.LastError)
430
+ }
431
+ msg := "run completed"
432
+ if state == run.StateCanceled {
433
+ msg = "run canceled"
434
+ }
435
+ slog.Info(msg, attrs...)
436
+ return nil
437
+ }
438
+
439
+ func (a *Activities) ProjectionUpdateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
440
+ return a.Runs.UpdateRetry(ctx, id, status)
441
+ }
442
+
443
+ // MailboxSpecForNode builds the mailbox description for a work node. The
444
+ // description defines the node work: node identity, parent, type, agent,
445
+ // description, and every legal route with its when explanation.
446
+ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflow.Node) task.MailboxSpec {
447
+ var b strings.Builder
448
+ b.WriteString(`Required report format:
449
+
450
+ STATUS: success | failure
451
+ NEXT STEP: <one valid node name>
452
+
453
+ SUMMARY:
454
+ COMPLETED:
455
+ COMMITS:
456
+ NOT COMPLETED:
457
+ ISSUES DISCOVERED:
458
+ VERIFICATION:
459
+ NOTES:
460
+
461
+ FEEDBACK:
462
+ REASON FOR NEXT STEP:
463
+ REQUIRED ACTIONS:
464
+ RELEVANT CONTEXT:
465
+ EXPECTED RESULT:
466
+
467
+ Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
468
+
469
+ Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
470
+ writeRoutes := func(label string, routes []workflow.Route) {
471
+ if len(routes) == 0 {
472
+ return
473
+ }
474
+ fmt.Fprintf(&b, "\n\n%s:", label)
475
+ for _, r := range routes {
476
+ if r.When != "" {
477
+ fmt.Fprintf(&b, "\n- %s — when: %s", r.Target, r.When)
478
+ } else {
479
+ fmt.Fprintf(&b, "\n- %s", r.Target)
480
+ }
481
+ }
482
+ }
483
+ writeRoutes("On success", n.OnSuccess)
484
+ writeRoutes("On failure", n.OnFailure)
485
+ successRoutes := routesText(n.OnSuccess)
486
+ failureRoutes := routesText(n.OnFailure)
487
+ return task.MailboxSpec{
488
+ Node: name,
489
+ Title: ticketKey + ":" + name,
490
+ Description: b.String(),
491
+ TaskConfig: n.TaskConfig,
492
+ TextData: task.TextData{
493
+ Ticket: ticketKey, Workflow: wf.Name, Node: name, NodeType: string(n.Type),
494
+ Agent: n.Agent, NodeDescription: n.Description,
495
+ NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
496
+ SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
497
+ Mailbox: ticketKey + ":" + name,
498
+ },
499
+ }
500
+ }
501
+
502
+ // MailboxSpecs returns one spec per work node, sorted for determinism.
503
+ func MailboxSpecs(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
504
+ names := make([]string, 0, len(wf.Nodes))
505
+ for name, n := range wf.Nodes {
506
+ if n.Type == workflow.NodeAgent || n.Type == workflow.NodeHITL {
507
+ names = append(names, name)
508
+ }
509
+ }
510
+ sort.Strings(names)
511
+ out := make([]task.MailboxSpec, 0, len(names))
512
+ for _, name := range names {
513
+ out = append(out, MailboxSpecForNode(wf, ticketKey, name, wf.Nodes[name]))
514
+ }
515
+ return out
516
+ }
517
+
518
+ // RenderMailboxSpecs asks the selected task system to render customizable
519
+ // mailbox text, then appends the fixed report contract and legal routes.
520
+ // It is shared by normal execution and explicit database-loss recovery.
521
+ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]task.MailboxSpec, error) {
522
+ specs := MailboxSpecs(wf, w.Parent.Key)
523
+ for i := range specs {
524
+ data := specs[i].TextData
525
+ data.RunID = string(w.RunID)
526
+ data.Repo = w.Repo
527
+ custom, err := sys.RenderText(task.TextMailboxDescription, data)
528
+ if err != nil {
529
+ return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
530
+ }
531
+ specs[i].Description = appendText(custom, specs[i].Description)
532
+ }
533
+ return specs, nil
534
+ }
535
+
536
+ func routesText(routes []workflow.Route) string {
537
+ var b strings.Builder
538
+ for i, route := range routes {
539
+ if i > 0 {
540
+ b.WriteString("\n")
541
+ }
542
+ b.WriteString(route.Target)
543
+ if route.When != "" {
544
+ b.WriteString(" — when: " + route.When)
545
+ }
546
+ }
547
+ return b.String()
548
+ }
549
+
550
+ func appendText(first, second string) string {
551
+ if first == "" {
552
+ return second
553
+ }
554
+ if second == "" {
555
+ return first
556
+ }
557
+ return first + "\n\n" + second
558
+ }
559
+
560
+ // mergeTaskConfig overlays node task config onto workflow task config using
561
+ // the shared deterministic merge (maps recursively, scalar/list replace).
562
+ func mergeTaskConfig(wfCfg, nodeCfg map[string]any) map[string]any {
563
+ if len(wfCfg) == 0 && len(nodeCfg) == 0 {
564
+ return nil
565
+ }
566
+ return config.Merge(wfCfg, nodeCfg)
567
+ }