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,736 @@
1
+ package temporal
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "errors"
6
+ "fmt"
7
+ "log/slog"
8
+ "sort"
9
+ "strings"
10
+ "time"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/harness"
13
+ "github.com/rajpopat27/relay-flow/internal/identity"
14
+ "github.com/rajpopat27/relay-flow/internal/retry"
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
+ domainworkflow "github.com/rajpopat27/relay-flow/internal/workflow"
19
+ temporalSDK "go.temporal.io/sdk/temporal"
20
+ temporalworkflow "go.temporal.io/sdk/workflow"
21
+ )
22
+
23
+ const (
24
+ reportSignalName = "report"
25
+ reconcileSignalName = "reconcile"
26
+ cancelReasonSignalName = "cancel-reason"
27
+ runStateQuery = "relay-flow/run-state-v1"
28
+ reportStateQuery = "relay-flow/report-state-v1"
29
+
30
+ activityEnsureMailboxes = "EnsureMailboxes"
31
+ activityPrepareRestart = "PrepareRestart"
32
+ activityValidateAgents = "ValidateAgents"
33
+ activityApplyTaskConfig = "ApplyTaskConfig"
34
+ activityEnsureEnvironment = "EnsureEnvironment"
35
+ activitySetEnvironmentStatus = "SetEnvironmentStatus"
36
+ activityLoadNodeRuntime = "LoadNodeRuntime"
37
+ activityEnsureNodeRuntime = "EnsureNodeRuntime"
38
+ activityCloseTerminals = "CloseTerminals"
39
+ activityCleanupRun = "CleanupRun"
40
+ activityCheckpointNodeRuntime = "CheckpointNodeRuntime"
41
+ activityFinalizeNodeRuntimes = "FinalizeNodeRuntimes"
42
+ activityComment = "Comment"
43
+ activityCompleteMailbox = "CompleteMailbox"
44
+ activityProjectionUpdateNodeRuntime = "ProjectionUpdateNodeRuntimeVisit"
45
+ activityProjectionRecordReport = "ProjectionRecordProcessedReport"
46
+ activityProjectionUpdateNode = "ProjectionUpdateNode"
47
+ activityProjectionUpdateState = "ProjectionUpdateState"
48
+ activityProjectionUpdateRetry = "ProjectionUpdateRetry"
49
+ )
50
+
51
+ type reportSignal struct {
52
+ ReportID string `json:"reportId"`
53
+ Node string `json:"node"`
54
+ NodeVisitID run.NodeVisitID `json:"nodeVisitId"`
55
+ Report domainworkflow.Report `json:"report"`
56
+ }
57
+
58
+ type cancelReasonSignal struct {
59
+ Reason string `json:"reason"`
60
+ }
61
+
62
+ // NodeRuntimeBinding is the serializable runtime information returned by the
63
+ // run-state query. It contains only runner/session identifiers, never live
64
+ // dependency objects.
65
+ type NodeRuntimeBinding struct {
66
+ Node string `json:"node"`
67
+ TerminalID string `json:"terminalId"`
68
+ SessionID string `json:"sessionId"`
69
+ NodeVisitID run.NodeVisitID `json:"nodeVisitId"`
70
+ }
71
+
72
+ type RunStateSnapshot struct {
73
+ Run run.Run `json:"run"`
74
+ RuntimeBindings []NodeRuntimeBinding `json:"runtimeBindings"`
75
+ }
76
+
77
+ type ReportStateQuery struct {
78
+ ReportID string `json:"reportId"`
79
+ }
80
+
81
+ type ReportStateSnapshot struct {
82
+ CurrentNode string `json:"currentNode"`
83
+ CurrentNodeVisitID run.NodeVisitID `json:"currentNodeVisitId"`
84
+ State run.State `json:"state"`
85
+ Processed bool `json:"processed"`
86
+ }
87
+
88
+ type workflowState struct {
89
+ run run.Run
90
+ bindings map[string]NodeRuntimeBinding
91
+ processed map[string]bool
92
+ }
93
+
94
+ func (s *workflowState) snapshot() RunStateSnapshot {
95
+ keys := make([]string, 0, len(s.bindings))
96
+ for node := range s.bindings {
97
+ keys = append(keys, node)
98
+ }
99
+ sort.Strings(keys)
100
+ bindings := make([]NodeRuntimeBinding, 0, len(keys))
101
+ for _, node := range keys {
102
+ bindings = append(bindings, s.bindings[node])
103
+ }
104
+ return RunStateSnapshot{Run: s.run, RuntimeBindings: bindings}
105
+ }
106
+
107
+ var temporalActivityOptions = temporalworkflow.ActivityOptions{
108
+ StartToCloseTimeout: 5 * time.Minute,
109
+ WaitForCancellation: true,
110
+ RetryPolicy: &temporalSDK.RetryPolicy{MaximumAttempts: 1},
111
+ }
112
+
113
+ func executeActivity[T any](ctx temporalworkflow.Context, name string, args ...interface{}) (T, error) {
114
+ var out T
115
+ activityCtx := temporalworkflow.WithActivityOptions(ctx, temporalActivityOptions)
116
+ err := temporalworkflow.ExecuteActivity(activityCtx, name, args...).Get(activityCtx, &out)
117
+ return out, err
118
+ }
119
+
120
+ func temporalJitter(ctx temporalworkflow.Context) float64 {
121
+ var value float64
122
+ err := temporalworkflow.SideEffect(ctx, func(temporalworkflow.Context) interface{} {
123
+ var b [1]byte
124
+ if _, err := rand.Read(b[:]); err != nil {
125
+ return float64(0)
126
+ }
127
+ return float64(b[0]) / 256
128
+ }).Get(&value)
129
+ if err != nil {
130
+ return 0.5
131
+ }
132
+ return value
133
+ }
134
+
135
+ // TicketWorkflow is the single generic workflow registered by the Temporal
136
+ // worker. Its input is an immutable run.Start snapshot; no workflow YAML or
137
+ // live dependency is loaded from inside workflow code.
138
+ func TicketWorkflow(ctx temporalworkflow.Context, start run.Start) error {
139
+ if start.LogicalID == "" {
140
+ start.LogicalID = run.ID(identity.LogicalRunID(start.ID))
141
+ }
142
+ if start.AttemptID == 0 {
143
+ start.AttemptID = 1
144
+ }
145
+ cancellationReason := "canceled"
146
+ cancelReasonCh := temporalworkflow.GetSignalChannel(ctx, cancelReasonSignalName)
147
+ state := newWorkflowState(ctx, start)
148
+ if err := temporalworkflow.SetQueryHandler(ctx, runStateQuery, func() (RunStateSnapshot, error) {
149
+ return state.snapshot(), nil
150
+ }); err != nil {
151
+ return err
152
+ }
153
+ if err := temporalworkflow.SetQueryHandler(ctx, reportStateQuery, func(query ReportStateQuery) (ReportStateSnapshot, error) {
154
+ return ReportStateSnapshot{
155
+ CurrentNode: state.run.CurrentNode, CurrentNodeVisitID: state.run.CurrentNodeVisitID,
156
+ State: state.run.State, Processed: state.processed[query.ReportID],
157
+ }, nil
158
+ }); err != nil {
159
+ return err
160
+ }
161
+ if err := runGraph(ctx, start, state, cancelReasonCh, &cancellationReason); err != nil {
162
+ if temporalSDK.IsCanceledError(err) || temporalSDK.IsCanceledError(ctx.Err()) {
163
+ work := run.Work{
164
+ RunID: start.ID, LogicalID: start.LogicalID, AttemptID: start.AttemptID,
165
+ Repo: start.Repo, Workflow: start.Workflow.Name, Parent: start.Ticket,
166
+ WorkflowTaskConfig: start.Workflow.TaskConfig, Runtime: start.Runtime,
167
+ }
168
+ var signal cancelReasonSignal
169
+ for cancelReasonCh.ReceiveAsync(&signal) {
170
+ if signal.Reason != "" {
171
+ cancellationReason = signal.Reason
172
+ }
173
+ }
174
+ state.run.State = run.StateCanceling
175
+ state.run.LastError = cancellationReason
176
+ state.run.UpdatedAt = temporalworkflow.Now(ctx)
177
+ if cleanupErr := cancelCleanup(ctx, state, work, start.RepoPath, cancellationReason); cleanupErr != nil {
178
+ return cleanupErr
179
+ }
180
+ state.run.State = run.StateCanceled
181
+ state.run.UpdatedAt = temporalworkflow.Now(ctx)
182
+ now := state.run.UpdatedAt
183
+ state.run.FinishedAt = &now
184
+ for node, binding := range state.bindings {
185
+ binding.TerminalID = ""
186
+ if !work.Runtime.KeepSessionsAlive {
187
+ binding.SessionID = ""
188
+ }
189
+ state.bindings[node] = binding
190
+ }
191
+ return temporalSDK.NewCanceledError()
192
+ }
193
+ return err
194
+ }
195
+ return nil
196
+ }
197
+
198
+ func newWorkflowState(ctx temporalworkflow.Context, start run.Start) *workflowState {
199
+ info := temporalworkflow.GetInfo(ctx)
200
+ return &workflowState{
201
+ run: run.Run{
202
+ ID: start.ID, LogicalID: start.LogicalID, AttemptID: start.AttemptID,
203
+ Repo: start.Repo, Workflow: start.Workflow.Name, Ticket: start.Ticket,
204
+ State: run.StateStarting, StartedAt: info.WorkflowStartTime,
205
+ UpdatedAt: temporalworkflow.Now(ctx),
206
+ },
207
+ bindings: map[string]NodeRuntimeBinding{},
208
+ processed: map[string]bool{},
209
+ }
210
+ }
211
+
212
+ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowState, cancelReasonCh temporalworkflow.ReceiveChannel, cancellationReason *string) error {
213
+ wf := start.Workflow
214
+ work := run.Work{
215
+ RunID: start.ID, LogicalID: start.LogicalID, AttemptID: start.AttemptID,
216
+ Repo: start.Repo, Workflow: wf.Name, Parent: start.Ticket,
217
+ WorkflowTaskConfig: wf.TaskConfig, Runtime: start.Runtime,
218
+ }
219
+
220
+ specs := MailboxSpecs(&wf, start.Ticket.Key)
221
+ mailboxes, err := retryActivity(ctx, state, work, "", func() (map[string]task.Mailbox, error) {
222
+ return executeActivity[map[string]task.Mailbox](ctx, activityEnsureMailboxes, work, specs)
223
+ })
224
+ if err != nil {
225
+ return err
226
+ }
227
+ if start.AttemptID > 1 {
228
+ mailboxList := make([]task.Mailbox, 0, len(mailboxes))
229
+ for _, mailbox := range mailboxes {
230
+ mailboxList = append(mailboxList, mailbox)
231
+ }
232
+ sort.Slice(mailboxList, func(i, j int) bool { return mailboxList[i].Node < mailboxList[j].Node })
233
+ if _, err := retryActivity(ctx, state, work, "start", func() (struct{}, error) {
234
+ return executeActivity[struct{}](ctx, activityPrepareRestart, work, start.RepoPath, mailboxList)
235
+ }); err != nil {
236
+ return err
237
+ }
238
+ }
239
+
240
+ agentSet := map[string]bool{}
241
+ for _, node := range wf.Nodes {
242
+ if node.Agent != "" {
243
+ agentSet[node.Agent] = true
244
+ }
245
+ }
246
+ agents := make([]string, 0, len(agentSet))
247
+ for agent := range agentSet {
248
+ agents = append(agents, agent)
249
+ }
250
+ sort.Strings(agents)
251
+ if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
252
+ return executeActivity[struct{}](ctx, activityValidateAgents, start.RepoPath, agents)
253
+ }); err != nil {
254
+ return err
255
+ }
256
+
257
+ startNode := wf.Nodes["start"]
258
+ if _, err := retryActivity(ctx, state, work, "start", func() (struct{}, error) {
259
+ return executeActivity[struct{}](ctx, activityApplyTaskConfig, work, "start", (*task.Mailbox)(nil), mergeTaskConfig(wf.TaskConfig, startNode.TaskConfig))
260
+ }); err != nil {
261
+ return err
262
+ }
263
+ if _, err := retryActivity(ctx, state, work, "", func() (runner.Environment, error) {
264
+ return executeActivity[runner.Environment](ctx, activityEnsureEnvironment, work, start.RepoPath)
265
+ }); err != nil {
266
+ return err
267
+ }
268
+ target, err := wf.StartTarget()
269
+ if err != nil {
270
+ return err
271
+ }
272
+
273
+ current := target
274
+ for current != "end" {
275
+ node := wf.Nodes[current]
276
+ var visitID identity.NodeVisitID
277
+ if err := temporalworkflow.SideEffect(ctx, func(temporalworkflow.Context) interface{} {
278
+ return identity.NewNodeVisitID()
279
+ }).Get(&visitID); err != nil {
280
+ return err
281
+ }
282
+ visit := run.NodeVisitID(visitID)
283
+ runtime, err := retryActivity(ctx, state, work, current, func() (NodeRuntime, error) {
284
+ return executeActivity[NodeRuntime](ctx, activityLoadNodeRuntime, start.ID, current)
285
+ })
286
+ if err != nil {
287
+ return err
288
+ }
289
+ if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
290
+ return executeActivity[struct{}](ctx, activityProjectionUpdateNodeRuntime, start.ID, current, visit)
291
+ }); err != nil {
292
+ return err
293
+ }
294
+
295
+ mailbox := mailboxes[current]
296
+ nodeCfg := mergeTaskConfig(wf.TaskConfig, node.TaskConfig)
297
+ if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
298
+ return executeActivity[struct{}](ctx, activityApplyTaskConfig, work, current, &mailbox, nodeCfg)
299
+ }); err != nil {
300
+ return err
301
+ }
302
+ if _, err := retryActivity(ctx, state, work, current, func() (runner.Environment, error) {
303
+ return executeActivity[runner.Environment](ctx, activityEnsureEnvironment, work, start.RepoPath)
304
+ }); err != nil {
305
+ return err
306
+ }
307
+
308
+ nextSteps := append(append([]domainworkflow.Route{}, node.OnSuccess...), node.OnFailure...)
309
+ spec := harness.LaunchSpec{
310
+ RunID: start.ID, NodeVisitID: visit, RepoName: start.Repo, RepoPath: start.RepoPath,
311
+ Workflow: wf.Name, Ticket: start.Ticket.Key, Node: current, NodeType: node.Type,
312
+ Agent: node.Agent, Title: start.Ticket.Key + ":" + current, NudgePrompt: node.NudgePrompt,
313
+ PromptData: harness.PromptData{
314
+ TaskSystem: "", Ticket: start.Ticket.Key, Workflow: wf.Name, Repo: start.Repo,
315
+ Node: current, NodeType: node.Type, Agent: node.Agent, NodeDescription: node.Description,
316
+ NextSteps: nextStepsText(nextSteps), Mailbox: mailbox.Key,
317
+ },
318
+ NextSteps: nextSteps,
319
+ }
320
+ // TaskSystem is an adapter name rather than workflow state. The activity
321
+ // owns the concrete configured value; this field remains informational.
322
+ nodeWork := run.NodeWork{Work: work, Node: current, NodeVisitID: visit, Mailbox: mailbox, NodeTaskConfig: nodeCfg}
323
+ if runtime.SessionID != "" {
324
+ spec.ResumeID = runtime.SessionID
325
+ }
326
+ binding, err := retryActivity(ctx, state, work, current, func() (NodeRuntime, error) {
327
+ return executeActivity[NodeRuntime](ctx, activityEnsureNodeRuntime, nodeWork, start.RepoPath, spec, runtime)
328
+ })
329
+ if err != nil {
330
+ return err
331
+ }
332
+ state.bindings[current] = NodeRuntimeBinding{Node: current, TerminalID: binding.TerminalID, SessionID: binding.SessionID, NodeVisitID: visit}
333
+
334
+ if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
335
+ return executeActivity[struct{}](ctx, activityProjectionUpdateNode, start.ID, run.StateRunning, current, visit)
336
+ }); err != nil {
337
+ return err
338
+ }
339
+ state.run.State = run.StateRunning
340
+ state.run.CurrentNode = current
341
+ state.run.CurrentNodeVisitID = visit
342
+ state.run.UpdatedAt = temporalworkflow.Now(ctx)
343
+ if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
344
+ return executeActivity[struct{}](ctx, activityProjectionUpdateState, start.ID, run.StateWaiting, "", (*time.Time)(nil))
345
+ }); err != nil {
346
+ return err
347
+ }
348
+ state.run.State = run.StateWaiting
349
+ state.run.UpdatedAt = temporalworkflow.Now(ctx)
350
+
351
+ reportCh := temporalworkflow.GetSignalChannel(ctx, reportSignalName)
352
+ reconcileCh := temporalworkflow.GetSignalChannel(ctx, reconcileSignalName)
353
+ var accepted reportSignal
354
+ gotReport := false
355
+ var reconcileErr error
356
+ for !gotReport {
357
+ selector := temporalworkflow.NewSelector(ctx)
358
+ selector.AddReceive(reportCh, func(channel temporalworkflow.ReceiveChannel, more bool) {
359
+ if !more {
360
+ return
361
+ }
362
+ var signal reportSignal
363
+ channel.Receive(ctx, &signal)
364
+ if state.processed[signal.ReportID] || signal.Node != current || signal.NodeVisitID != visit {
365
+ return
366
+ }
367
+ accepted = signal
368
+ gotReport = true
369
+ })
370
+ selector.AddReceive(reconcileCh, func(channel temporalworkflow.ReceiveChannel, more bool) {
371
+ if !more {
372
+ return
373
+ }
374
+ var ignored struct{}
375
+ channel.Receive(ctx, &ignored)
376
+ if reconcileErr != nil {
377
+ return
378
+ }
379
+ var rebound NodeRuntime
380
+ rebound, reconcileErr = retryActivity(ctx, state, work, current, func() (NodeRuntime, error) {
381
+ return executeActivity[NodeRuntime](ctx, activityEnsureNodeRuntime, nodeWork, start.RepoPath, spec, NodeRuntime{NodeVisitID: visit})
382
+ })
383
+ if reconcileErr == nil {
384
+ state.bindings[current] = NodeRuntimeBinding{Node: current, TerminalID: rebound.TerminalID, SessionID: rebound.SessionID, NodeVisitID: visit}
385
+ }
386
+ })
387
+ selector.AddReceive(cancelReasonCh, func(channel temporalworkflow.ReceiveChannel, more bool) {
388
+ if !more {
389
+ return
390
+ }
391
+ var signal cancelReasonSignal
392
+ channel.Receive(ctx, &signal)
393
+ if signal.Reason != "" && cancellationReason != nil {
394
+ *cancellationReason = signal.Reason
395
+ }
396
+ })
397
+ selector.AddReceive(ctx.Done(), func(channel temporalworkflow.ReceiveChannel, more bool) {
398
+ if more {
399
+ channel.Receive(ctx, nil)
400
+ }
401
+ })
402
+ selector.Select(ctx)
403
+ if reconcileErr != nil {
404
+ return reconcileErr
405
+ }
406
+ if temporalSDK.IsCanceledError(ctx.Err()) {
407
+ return ctx.Err()
408
+ }
409
+ }
410
+ if err := wf.ValidateReport(current, accepted.Report); err != nil {
411
+ return fmt.Errorf("workflow %q node %q: invalid accepted report: %w", wf.Name, current, err)
412
+ }
413
+ state.processed[accepted.ReportID] = true
414
+ state.run.UpdatedAt = temporalworkflow.Now(ctx)
415
+ if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
416
+ return executeActivity[struct{}](ctx, activityProjectionRecordReport, start.ID, visit, accepted.ReportID)
417
+ }); err != nil {
418
+ return err
419
+ }
420
+
421
+ if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
422
+ return executeActivity[struct{}](ctx, activityComment, start.Repo, run.CommentWork{
423
+ RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &mailbox},
424
+ TextKind: task.TextSummaryComment,
425
+ TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key, Workflow: wf.Name,
426
+ Repo: start.Repo, Node: current, NodeType: string(node.Type), Agent: node.Agent,
427
+ NodeDescription: node.Description, Mailbox: mailbox.Key, SourceNode: current,
428
+ TargetNode: current, SummaryReport: renderSummaryReport(accepted.Report)},
429
+ Marker: string(visit) + ":summary",
430
+ })
431
+ }); err != nil {
432
+ return err
433
+ }
434
+ next := accepted.Report.NextStep
435
+ if next != "end" {
436
+ nextMailbox := mailboxes[next]
437
+ if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
438
+ return executeActivity[struct{}](ctx, activityComment, start.Repo, run.CommentWork{
439
+ RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &nextMailbox},
440
+ TextKind: task.TextFeedbackComment,
441
+ TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key, Workflow: wf.Name,
442
+ Repo: start.Repo, Node: next, NodeType: string(wf.Nodes[next].Type), Agent: wf.Nodes[next].Agent,
443
+ NodeDescription: wf.Nodes[next].Description, Mailbox: nextMailbox.Key, SourceNode: current,
444
+ TargetNode: next, SummaryReport: renderSummaryReport(accepted.Report),
445
+ FeedbackReport: renderFeedbackReport(accepted.Report)},
446
+ Marker: string(visit) + ":feedback",
447
+ })
448
+ }); err != nil {
449
+ return err
450
+ }
451
+ }
452
+ if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
453
+ return executeActivity[struct{}](ctx, activityCompleteMailbox, work, mailbox)
454
+ }); err != nil {
455
+ return err
456
+ }
457
+ if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
458
+ return executeActivity[struct{}](ctx, activityCheckpointNodeRuntime, nodeWork, start.RepoPath, work.Runtime)
459
+ }); err != nil {
460
+ return err
461
+ }
462
+ applyRuntimePolicy(state, current, work.Runtime)
463
+ current = next
464
+ }
465
+
466
+ endNode := wf.Nodes["end"]
467
+ if _, err := retryActivity(ctx, state, work, "end", func() (struct{}, error) {
468
+ return executeActivity[struct{}](ctx, activityApplyTaskConfig, work, "end", (*task.Mailbox)(nil), mergeTaskConfig(wf.TaskConfig, endNode.TaskConfig))
469
+ }); err != nil {
470
+ return err
471
+ }
472
+ if _, err := retryActivity(ctx, state, work, "end", func() (struct{}, error) {
473
+ return executeActivity[struct{}](ctx, activitySetEnvironmentStatus, work, start.RepoPath, runner.WorkspaceStatusCompleted)
474
+ }); err != nil {
475
+ return err
476
+ }
477
+ finalPolicy := work.Runtime
478
+ if wf.CleanupRunnerOnEnd {
479
+ finalPolicy.KeepTerminalsAlive = false
480
+ }
481
+ if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
482
+ return executeActivity[struct{}](ctx, activityFinalizeNodeRuntimes, work, start.RepoPath, finalPolicy)
483
+ }); err != nil {
484
+ return err
485
+ }
486
+ for node := range state.bindings {
487
+ applyRuntimePolicy(state, node, finalPolicy)
488
+ }
489
+ if wf.CleanupRunnerOnEnd {
490
+ if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
491
+ return executeActivity[struct{}](ctx, activityCleanupRun, work, start.RepoPath)
492
+ }); err != nil {
493
+ return err
494
+ }
495
+ }
496
+ now := temporalworkflow.Now(ctx).UTC()
497
+ if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
498
+ return executeActivity[struct{}](ctx, activityProjectionUpdateState, start.ID, run.StateCompleted, "", &now)
499
+ }); err != nil {
500
+ return err
501
+ }
502
+ state.run.State = run.StateCompleted
503
+ state.run.UpdatedAt = now
504
+ state.run.FinishedAt = &now
505
+ return nil
506
+ }
507
+
508
+ // retryProjectionActivity uses the same durable timer/backoff policy for
509
+ // relay projection bookkeeping. It intentionally does not recursively write
510
+ // retry metadata because this is the fallback path when that metadata write
511
+ // itself failed; Temporal history remains authoritative while SQLite heals.
512
+ func retryProjectionActivity[T any](ctx temporalworkflow.Context, state *workflowState, work run.Work, node string, action func() (T, error)) (T, error) {
513
+ var zero T
514
+ attempt := 0
515
+ for {
516
+ if err := ctx.Err(); err != nil {
517
+ return zero, err
518
+ }
519
+ result, err := action()
520
+ if err == nil {
521
+ return result, nil
522
+ }
523
+ if temporalSDK.IsCanceledError(err) || temporalSDK.IsCanceledError(ctx.Err()) {
524
+ if ctx.Err() != nil {
525
+ return zero, ctx.Err()
526
+ }
527
+ return zero, err
528
+ }
529
+ failure := classifyTemporalError(err)
530
+ delay := retry.DefaultBackoffPolicy.Delay(attempt, temporalJitter(ctx))
531
+ state.run.Retry = &run.RetryStatus{Attempt: attempt + 1, LastError: sanitizeRetryMessage(failure.Message), NextRetryAt: temporalworkflow.Now(ctx).UTC().Add(delay)}
532
+ state.run.LastError = state.run.Retry.LastError
533
+ state.run.UpdatedAt = temporalworkflow.Now(ctx)
534
+ logRetry(ctx, work, node, failure, delay)
535
+ if err := temporalworkflow.NewTimer(ctx, delay).Get(ctx, nil); err != nil {
536
+ return zero, err
537
+ }
538
+ attempt++
539
+ }
540
+ }
541
+
542
+ func retryActivity[T any](ctx temporalworkflow.Context, state *workflowState, work run.Work, node string, action func() (T, error)) (T, error) {
543
+ var zero T
544
+ attempt := 0
545
+ blocked := false
546
+ for {
547
+ if err := ctx.Err(); err != nil {
548
+ return zero, err
549
+ }
550
+ result, err := action()
551
+ if err == nil {
552
+ if attempt > 0 {
553
+ if _, clearErr := retryProjectionActivity(ctx, state, work, node, func() (struct{}, error) {
554
+ return executeActivity[struct{}](ctx, activityProjectionUpdateRetry, work.RunID, (*run.RetryStatus)(nil))
555
+ }); clearErr != nil {
556
+ return zero, clearErr
557
+ }
558
+ state.run.Retry = nil
559
+ state.run.LastError = ""
560
+ state.run.UpdatedAt = temporalworkflow.Now(ctx)
561
+ }
562
+ if blocked {
563
+ if _, stateErr := retryProjectionActivity(ctx, state, work, node, func() (struct{}, error) {
564
+ return executeActivity[struct{}](ctx, activityProjectionUpdateState, work.RunID, run.StateWaiting, "", (*time.Time)(nil))
565
+ }); stateErr != nil {
566
+ return zero, stateErr
567
+ }
568
+ state.run.State = run.StateWaiting
569
+ }
570
+ return result, nil
571
+ }
572
+ if temporalSDK.IsCanceledError(err) || temporalSDK.IsCanceledError(ctx.Err()) {
573
+ if ctx.Err() != nil {
574
+ return zero, ctx.Err()
575
+ }
576
+ return zero, err
577
+ }
578
+ failure := classifyTemporalError(err)
579
+ if failure.Kind == retry.Conflict {
580
+ failure.Message = blockedMessage(work, node, failure.Message)
581
+ blocked = true
582
+ state.run.State = run.StateBlocked
583
+ if _, stateErr := retryProjectionActivity(ctx, state, work, node, func() (struct{}, error) {
584
+ return executeActivity[struct{}](ctx, activityProjectionUpdateState, work.RunID, run.StateBlocked, failure.Message, (*time.Time)(nil))
585
+ }); stateErr != nil {
586
+ return zero, stateErr
587
+ }
588
+ }
589
+ delay := retry.DefaultBackoffPolicy.Delay(attempt, temporalJitter(ctx))
590
+ logRetry(ctx, work, node, failure, delay)
591
+ nextRetry := temporalworkflow.Now(ctx).UTC().Add(delay)
592
+ status := &run.RetryStatus{Attempt: attempt + 1, LastError: sanitizeRetryMessage(failure.Message), NextRetryAt: nextRetry}
593
+ state.run.Retry = status
594
+ state.run.LastError = status.LastError
595
+ state.run.UpdatedAt = temporalworkflow.Now(ctx)
596
+ if _, projectionErr := retryProjectionActivity(ctx, state, work, node, func() (struct{}, error) {
597
+ return executeActivity[struct{}](ctx, activityProjectionUpdateRetry, work.RunID, status)
598
+ }); projectionErr != nil {
599
+ return zero, projectionErr
600
+ }
601
+ if err := temporalworkflow.NewTimer(ctx, delay).Get(ctx, nil); err != nil {
602
+ return zero, err
603
+ }
604
+ attempt++
605
+ }
606
+ }
607
+
608
+ func blockedMessage(work run.Work, node, message string) string {
609
+ message = strings.TrimRight(message, ". ")
610
+ lower := strings.ToLower(message)
611
+ if node == "start" && !strings.Contains(lower, "mailbox") {
612
+ return fmt.Sprintf("%s. Move ticket %s to an allowed active start status; relay-flow will retry automatically", message, work.Parent.Key)
613
+ }
614
+ if node != "" {
615
+ return fmt.Sprintf("%s. Restore the task-system state required for node %s; relay-flow will retry automatically", message, node)
616
+ }
617
+ return fmt.Sprintf("%s. Restore the task-system state required by this operation; relay-flow will retry automatically", message)
618
+ }
619
+
620
+ func classifyTemporalError(err error) retry.Failure {
621
+ if err == nil {
622
+ return retry.Failure{}
623
+ }
624
+ var applicationErr *temporalSDK.ApplicationError
625
+ if errors.As(err, &applicationErr) {
626
+ typ := strings.ToLower(applicationErr.Type())
627
+ if typ == string(retry.Conflict) || strings.Contains(typ, "conflicterror") {
628
+ return retry.Failure{Kind: retry.Conflict, Message: applicationErr.Error()}
629
+ }
630
+ if typ == string(retry.Transient) {
631
+ return retry.Failure{Kind: retry.Transient, Message: applicationErr.Error()}
632
+ }
633
+ }
634
+ return retry.Classify(err)
635
+ }
636
+
637
+ func logRetry(ctx temporalworkflow.Context, work run.Work, node string, failure retry.Failure, delay time.Duration) {
638
+ attrs := []interface{}{
639
+ "ticket", work.Parent.Key, "runID", string(work.RunID), "repo", work.Repo,
640
+ "workflow", work.Workflow, "kind", string(failure.Kind), "delayMs", delay.Milliseconds(),
641
+ "error", sanitizeRetryMessage(failure.Message),
642
+ }
643
+ if node != "" {
644
+ attrs = append(attrs, "node", node)
645
+ }
646
+ _ = temporalworkflow.SideEffect(ctx, func(temporalworkflow.Context) interface{} {
647
+ slog.Info("retry scheduled", attrs...)
648
+ return struct{}{}
649
+ })
650
+ }
651
+
652
+ func sanitizeRetryMessage(s string) string {
653
+ for {
654
+ i := strings.Index(s, "[")
655
+ if i < 0 {
656
+ return s
657
+ }
658
+ j := strings.Index(s[i:], "]: ")
659
+ if j < 0 {
660
+ return s
661
+ }
662
+ s = strings.TrimSuffix(s[:i], " ") + s[i+j+3:]
663
+ }
664
+ }
665
+
666
+ func cancelCleanup(ctx temporalworkflow.Context, state *workflowState, work run.Work, repoPath, reason string) error {
667
+ dctx, cancel := temporalworkflow.NewDisconnectedContext(ctx)
668
+ defer cancel()
669
+ cleanupPolicy := work.Runtime
670
+ // Cancellation always closes run-owned terminals while preserving the
671
+ // workspace and reusable session metadata. KeepTerminalsAlive controls
672
+ // normal checkpoints/end cleanup, not cancellation cleanup.
673
+ cleanupPolicy.KeepTerminalsAlive = false
674
+ if _, err := retryActivity(dctx, state, work, "", func() (struct{}, error) {
675
+ return executeActivity[struct{}](dctx, activityFinalizeNodeRuntimes, work, repoPath, cleanupPolicy)
676
+ }); err != nil {
677
+ return err
678
+ }
679
+ markerID := work.LogicalID
680
+ if markerID == "" {
681
+ markerID = work.RunID
682
+ }
683
+ if _, err := retryActivity(dctx, state, work, "", func() (struct{}, error) {
684
+ return executeActivity[struct{}](dctx, activityComment, work.Repo, run.CommentWork{
685
+ RunID: work.RunID, Item: task.Target{Parent: work.Parent},
686
+ Body: "Run canceled: " + reason, Marker: run.CancellationMarker(markerID),
687
+ })
688
+ }); err != nil {
689
+ return err
690
+ }
691
+ now := temporalworkflow.Now(dctx).UTC()
692
+ if _, err := retryActivity(dctx, state, work, "", func() (struct{}, error) {
693
+ return executeActivity[struct{}](dctx, activityProjectionUpdateState, work.RunID, run.StateCanceled, "", &now)
694
+ }); err != nil {
695
+ return err
696
+ }
697
+ return nil
698
+ }
699
+
700
+ func nextStepsText(routes []domainworkflow.Route) string {
701
+ var b strings.Builder
702
+ for i, route := range routes {
703
+ if i > 0 {
704
+ b.WriteString("; ")
705
+ }
706
+ b.WriteString(route.Target)
707
+ if route.When != "" {
708
+ b.WriteString(" (when: " + route.When + ")")
709
+ }
710
+ }
711
+ return b.String()
712
+ }
713
+
714
+ func applyRuntimePolicy(state *workflowState, node string, policy run.RuntimePolicy) {
715
+ binding, ok := state.bindings[node]
716
+ if !ok {
717
+ return
718
+ }
719
+ if !policy.KeepTerminalsAlive {
720
+ binding.TerminalID = ""
721
+ }
722
+ if !policy.KeepSessionsAlive {
723
+ binding.SessionID = ""
724
+ }
725
+ state.bindings[node] = binding
726
+ }
727
+
728
+ func renderSummaryReport(r domainworkflow.Report) string {
729
+ return fmt.Sprintf("COMPLETED:\n%s\n\nCOMMITS:\n%s\n\nNOT COMPLETED:\n%s\n\nISSUES DISCOVERED:\n%s\n\nVERIFICATION:\n%s\n\nNOTES:\n%s",
730
+ r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
731
+ }
732
+
733
+ func renderFeedbackReport(r domainworkflow.Report) string {
734
+ return fmt.Sprintf("COMMITS:\n%s\n\nREASON FOR NEXT STEP:\n%s\n\nREQUIRED ACTIONS:\n%s\n\nRELEVANT CONTEXT:\n%s\n\nEXPECTED RESULT:\n%s",
735
+ r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
736
+ }