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