relay-flow 0.0.1 → 0.2.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. package/internal/tasks/tasks_test.go +0 -91
@@ -0,0 +1,605 @@
1
+ package goworkflows
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "fmt"
6
+ "log/slog"
7
+ "reflect"
8
+ "sort"
9
+ "strings"
10
+ "time"
11
+
12
+ goworkflow "github.com/cschleiden/go-workflows/workflow"
13
+
14
+ "github.com/rajpopat27/relay-flow/internal/harness"
15
+ "github.com/rajpopat27/relay-flow/internal/identity"
16
+ "github.com/rajpopat27/relay-flow/internal/retry"
17
+ "github.com/rajpopat27/relay-flow/internal/run"
18
+ "github.com/rajpopat27/relay-flow/internal/runner"
19
+ "github.com/rajpopat27/relay-flow/internal/task"
20
+ "github.com/rajpopat27/relay-flow/internal/workflow"
21
+ )
22
+
23
+ // Signal names. The workflow deduplicates report IDs across all node visits;
24
+ // the reconcile channel asks it to relaunch the current visit's terminal.
25
+ const (
26
+ reportSignalName = "report"
27
+ reconcileSignal = "reconcile"
28
+ )
29
+
30
+ type reportSignal struct {
31
+ ReportID string
32
+ Node string
33
+ NodeVisitID run.NodeVisitID
34
+ Report workflow.Report
35
+ }
36
+
37
+ // noNativeRetries keeps the engine-native activity retry count at one; the
38
+ // private typed retry loops below implement the shared backoff policy.
39
+ var noNativeRetries = goworkflow.ActivityOptions{
40
+ RetryOptions: goworkflow.RetryOptions{MaxAttempts: 1},
41
+ }
42
+
43
+ // jitter draws a replay-safe random in [0,1) for backoff.
44
+ func jitter(ctx goworkflow.Context) (float64, error) {
45
+ return goworkflow.SideEffect(ctx, func(goworkflow.Context) float64 {
46
+ var b [1]byte
47
+ if _, err := rand.Read(b[:]); err != nil {
48
+ return 0
49
+ }
50
+ return float64(b[0]) / 256
51
+ }).Get(ctx)
52
+ }
53
+
54
+ // TicketWorkflow is the one generic interpreter registered with the workflow
55
+ // worker. It consumes ONLY the workflow value snapshot carried in start; it
56
+ // never re-reads the workflow file or registry mid-run, so replay is
57
+ // deterministic. On cancellation it runs cancellation cleanup on a
58
+ // disconnected context.
59
+ func (a *Activities) TicketWorkflow(ctx goworkflow.Context, start run.Start) error {
60
+ err := a.runGraph(ctx, start)
61
+ if err != nil && ctx.Err() != nil {
62
+ work := run.Work{
63
+ RunID: start.ID,
64
+ Repo: start.Repo,
65
+ Workflow: start.Workflow.Name,
66
+ Parent: start.Ticket,
67
+ WorkflowTaskConfig: start.Workflow.TaskConfig,
68
+ Runtime: start.Runtime,
69
+ }
70
+ return a.cancelCleanup(ctx, work, start.RepoPath, "canceled")
71
+ }
72
+ return err
73
+ }
74
+
75
+ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
76
+ wf := start.Workflow // value snapshot
77
+ work := run.Work{
78
+ RunID: start.ID,
79
+ Repo: start.Repo,
80
+ Workflow: wf.Name,
81
+ Parent: start.Ticket,
82
+ WorkflowTaskConfig: wf.TaskConfig,
83
+ Runtime: start.Runtime,
84
+ }
85
+
86
+ // Ensure every work-node mailbox (find existing, create only missing).
87
+ specs := MailboxSpecs(&wf, start.Ticket.Key)
88
+ mailboxes, err := retryLoop(ctx, start.ID, a, work, "",
89
+ func(ctx2 goworkflow.Context) goworkflow.Future[map[string]task.Mailbox] {
90
+ return goworkflow.ExecuteActivity[map[string]task.Mailbox](ctx2, noNativeRetries, a.EnsureMailboxes, work, specs)
91
+ })
92
+ if err != nil {
93
+ return err
94
+ }
95
+
96
+ // Validate every referenced agent before the start edge.
97
+ agentSet := map[string]bool{}
98
+ for _, n := range wf.Nodes {
99
+ if n.Agent != "" {
100
+ agentSet[n.Agent] = true
101
+ }
102
+ }
103
+ agentList := make([]string, 0, len(agentSet))
104
+ for agent := range agentSet {
105
+ agentList = append(agentList, agent)
106
+ }
107
+ sort.Strings(agentList)
108
+ if _, err := retryLoop(ctx, start.ID, a, work, "",
109
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
110
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ValidateAgents, start.RepoPath, agentList)
111
+ }); err != nil {
112
+ return err
113
+ }
114
+
115
+ // Process the reserved start taskConfig (parent target).
116
+ startNode := wf.Nodes["start"]
117
+ if _, err := retryLoop(ctx, start.ID, a, work, "start",
118
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
119
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ApplyTaskConfig,
120
+ work, "start", (*task.Mailbox)(nil), mergeTaskConfig(wf.TaskConfig, startNode.TaskConfig))
121
+ }); err != nil {
122
+ return err
123
+ }
124
+
125
+ // Ensure the runner environment before following the start edge.
126
+ if _, err := retryLoop(ctx, start.ID, a, work, "",
127
+ func(ctx2 goworkflow.Context) goworkflow.Future[runner.Environment] {
128
+ return goworkflow.ExecuteActivity[runner.Environment](ctx2, noNativeRetries, a.EnsureEnvironment, work, start.RepoPath)
129
+ }); err != nil {
130
+ return err
131
+ }
132
+
133
+ target, err := wf.StartTarget()
134
+ if err != nil {
135
+ return err
136
+ }
137
+
138
+ current := target
139
+ seenReportIDs := map[string]bool{}
140
+ for current != "end" {
141
+ node := wf.Nodes[current]
142
+
143
+ // One fresh visit identity per node entry, durable across replay.
144
+ visit, err := goworkflow.SideEffect(ctx, func(goworkflow.Context) identity.NodeVisitID {
145
+ return identity.NewNodeVisitID()
146
+ }).Get(ctx)
147
+ if err != nil {
148
+ return err
149
+ }
150
+ visitID := run.NodeVisitID(visit)
151
+ runtime, err := retryLoop(ctx, start.ID, a, work, current,
152
+ func(ctx2 goworkflow.Context) goworkflow.Future[NodeRuntime] {
153
+ return goworkflow.ExecuteActivity[NodeRuntime](ctx2, noNativeRetries,
154
+ a.LoadNodeRuntime, start.ID, current)
155
+ })
156
+ if err != nil {
157
+ return err
158
+ }
159
+
160
+ // Publish the visit guard before launching OpenCode so its earliest
161
+ // session event can register against this exact run/node/visit tuple.
162
+ // Revisit preparation preserves reusable terminal/session IDs.
163
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
164
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
165
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
166
+ a.ProjectionUpdateNodeRuntimeVisit, start.ID, current, visitID)
167
+ }); err != nil {
168
+ return err
169
+ }
170
+
171
+ // Process the node in the task system.
172
+ mb := mailboxes[current]
173
+ nodeCfg := mergeTaskConfig(wf.TaskConfig, node.TaskConfig)
174
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
175
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
176
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ApplyTaskConfig, work, current, &mb, nodeCfg)
177
+ }); err != nil {
178
+ return err
179
+ }
180
+
181
+ // Ensure the runner environment for this node entry (idempotent).
182
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
183
+ func(ctx2 goworkflow.Context) goworkflow.Future[runner.Environment] {
184
+ return goworkflow.ExecuteActivity[runner.Environment](ctx2, noNativeRetries, a.EnsureEnvironment, work, start.RepoPath)
185
+ }); err != nil {
186
+ return err
187
+ }
188
+
189
+ title := start.Ticket.Key + ":" + current
190
+
191
+ // Build the finished launch metadata from the workflow snapshot: the
192
+ // prompt carries the node description, optional custom instructions,
193
+ // complete report contract, and valid next steps. Custom instructions
194
+ // are also retained separately for live-terminal revisits.
195
+ nextSteps := append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...)
196
+ nudge, err := wf.RenderNudge(current, workflow.NudgeTemplateData{
197
+ Ticket: start.Ticket.Key,
198
+ Workflow: wf.Name,
199
+ Repo: start.Repo,
200
+ Node: current,
201
+ NextSteps: nextStepsText(nextSteps),
202
+ })
203
+ if err != nil {
204
+ return err
205
+ }
206
+ spec := harness.LaunchSpec{
207
+ RunID: start.ID,
208
+ NodeVisitID: visitID,
209
+ RepoName: start.Repo,
210
+ RepoPath: start.RepoPath,
211
+ Workflow: wf.Name,
212
+ Ticket: start.Ticket.Key,
213
+ Node: current,
214
+ NodeType: node.Type,
215
+ Agent: node.Agent,
216
+ Title: title,
217
+ Prompt: BuildLaunchSpecPrompt(start.Ticket.Key, mb.Key),
218
+ NudgePrompt: nudge,
219
+ NextSteps: nextSteps,
220
+ }
221
+ if runtime.SessionID != "" {
222
+ spec.ResumeID = runtime.SessionID
223
+ }
224
+ nodeWork := run.NodeWork{
225
+ Work: work, Node: current, NodeVisitID: visitID,
226
+ Mailbox: mb, NodeTaskConfig: nodeCfg,
227
+ }
228
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
229
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
230
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
231
+ a.EnsureNodeRuntime, nodeWork, start.RepoPath, spec, runtime)
232
+ }); err != nil {
233
+ return err
234
+ }
235
+
236
+ // Publish the current node+visit only after the terminal exists, so
237
+ // observers never see a visit whose terminal is not up yet.
238
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
239
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
240
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ProjectionUpdateNode, start.ID, run.StateRunning, current, visitID)
241
+ }); err != nil {
242
+ return err
243
+ }
244
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
245
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
246
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ProjectionUpdateState, start.ID, run.StateWaiting, "", (*time.Time)(nil))
247
+ }); err != nil {
248
+ return err
249
+ }
250
+
251
+ // Wait for this visit's report; duplicate report IDs are ignored across
252
+ // the entire run, including later revisits to this node.
253
+ reportCh := goworkflow.NewSignalChannel[reportSignal](ctx, reportSignalName)
254
+ reconcileCh := goworkflow.NewSignalChannel[struct{}](ctx, reconcileSignal)
255
+ var report workflow.Report
256
+ var reportID string
257
+ gotReport := false
258
+ for !gotReport && ctx.Err() == nil {
259
+ goworkflow.Select(ctx,
260
+ goworkflow.Receive(reportCh, func(_ goworkflow.Context, signal reportSignal, ok bool) {
261
+ if seenReportIDs[signal.ReportID] {
262
+ return
263
+ }
264
+ seenReportIDs[signal.ReportID] = true
265
+ if signal.Node != current || signal.NodeVisitID != visitID {
266
+ return
267
+ }
268
+ reportID = signal.ReportID
269
+ report = signal.Report
270
+ gotReport = true
271
+ }),
272
+ goworkflow.Receive(reconcileCh, func(ctx2 goworkflow.Context, _ struct{}, ok bool) {
273
+ // Reconcile addresses only the persisted direct identity;
274
+ // missing/unusable IDs are replaced without discovery.
275
+ _, _ = retryLoop(ctx2, start.ID, a, work, current,
276
+ func(ctx3 goworkflow.Context) goworkflow.Future[struct{}] {
277
+ return goworkflow.ExecuteActivity[struct{}](ctx3, noNativeRetries,
278
+ a.EnsureNodeRuntime, nodeWork, start.RepoPath, spec,
279
+ NodeRuntime{NodeVisitID: visitID})
280
+ })
281
+ }),
282
+ // Wake on cancellation; the loop condition then exits.
283
+ goworkflow.Receive(ctx.Done(), func(goworkflow.Context, struct{}, bool) {}),
284
+ )
285
+ }
286
+ if ctx.Err() != nil {
287
+ return ctx.Err()
288
+ }
289
+
290
+ // Validate against the current node; the server boundary already
291
+ // validated, and durable-side validation keeps replay honest.
292
+ if err := wf.ValidateReport(current, report); err != nil {
293
+ return fmt.Errorf("workflow %q node %q: invalid accepted report: %w", wf.Name, current, err)
294
+ }
295
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
296
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
297
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
298
+ a.ProjectionRecordProcessedReport, start.ID, visitID, reportID)
299
+ }); err != nil {
300
+ return err
301
+ }
302
+
303
+ // Ordered transition: summary -> feedback (selected next only) ->
304
+ // complete current -> process next node.
305
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
306
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
307
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
308
+ RunID: start.ID,
309
+ Item: task.Target{Parent: work.Parent, Mailbox: &mb},
310
+ Body: renderSummary(report),
311
+ Marker: string(visitID) + ":summary",
312
+ })
313
+ }); err != nil {
314
+ return err
315
+ }
316
+
317
+ next := report.NextStep
318
+ if next != "end" {
319
+ nextMb := mailboxes[next]
320
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
321
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
322
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
323
+ RunID: start.ID,
324
+ Item: task.Target{Parent: work.Parent, Mailbox: &nextMb},
325
+ Body: renderFeedback(current, report),
326
+ Marker: string(visitID) + ":feedback",
327
+ })
328
+ }); err != nil {
329
+ return err
330
+ }
331
+ }
332
+
333
+ // Complete the current mailbox. A conflict (a human moved it) marks
334
+ // the run blocked and keeps retrying until external state is
335
+ // compatible again; no blind overwrite ever runs.
336
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
337
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
338
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.CompleteMailbox, work, mb)
339
+ }); err != nil {
340
+ return err
341
+ }
342
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
343
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
344
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
345
+ a.CheckpointNodeRuntime, nodeWork, start.RepoPath, work.Runtime)
346
+ }); err != nil {
347
+ return err
348
+ }
349
+
350
+ current = next
351
+ }
352
+
353
+ // end: apply end task config, then optional runner cleanup, then mark
354
+ // the run completed.
355
+ endNode := wf.Nodes["end"]
356
+ if _, err := retryLoop(ctx, start.ID, a, work, "end",
357
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
358
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ApplyTaskConfig,
359
+ work, "end", (*task.Mailbox)(nil), mergeTaskConfig(wf.TaskConfig, endNode.TaskConfig))
360
+ }); err != nil {
361
+ return err
362
+ }
363
+ finalPolicy := work.Runtime
364
+ if wf.CleanupRunnerOnEnd {
365
+ finalPolicy.KeepTerminalsAlive = false
366
+ }
367
+ if _, err := retryLoop(ctx, start.ID, a, work, "",
368
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
369
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
370
+ a.FinalizeNodeRuntimes, work, start.RepoPath, finalPolicy)
371
+ }); err != nil {
372
+ return err
373
+ }
374
+ if wf.CleanupRunnerOnEnd {
375
+ if _, err := retryLoop(ctx, start.ID, a, work, "",
376
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
377
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.CleanupRun, work, start.RepoPath)
378
+ }); err != nil {
379
+ return err
380
+ }
381
+ }
382
+ if _, err := retryLoop(ctx, start.ID, a, work, "",
383
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
384
+ now := goworkflow.Now(ctx).UTC()
385
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ProjectionUpdateState, start.ID, run.StateCompleted, "", &now)
386
+ }); err != nil {
387
+ return err
388
+ }
389
+ return nil
390
+ }
391
+
392
+ // retryLoop runs one scheduled activity under the shared policy: transient
393
+ // failures retry forever with exponential backoff and replay-safe jitter;
394
+ // conflict failures mark the run blocked and keep retrying on the capped
395
+ // schedule. Cancellation stops scheduling.
396
+ //
397
+ // 9.6: each scheduling failure emits ONE info line with the retry
398
+ // classification (transient|conflict) and the next retry delay. work
399
+ // carries the always-known ticket/repo/workflow context; node is the
400
+ // enclosing node when the activity runs inside a node body (empty for
401
+ // run-level activities like EnsureMailboxes / start/end ApplyTaskConfig).
402
+ // The log is wrapped in a replay-safe SideEffect so replays never re-emit
403
+ // it, and the error message is sanitized so info logs never embed argv
404
+ // payloads (acli/orca --body/--command/JQL/prompt strings).
405
+ func retryLoop[T any](ctx goworkflow.Context, id run.ID, a *Activities, work run.Work, node string, schedule func(goworkflow.Context) goworkflow.Future[T]) (T, error) {
406
+ var zero T
407
+ attempt := 0
408
+ blocked := false
409
+ for {
410
+ if err := ctx.Err(); err != nil {
411
+ return zero, err
412
+ }
413
+ result, err := schedule(ctx).Get(ctx)
414
+ if err == nil {
415
+ if attempt > 0 {
416
+ if _, clearErr := goworkflow.ExecuteActivity[struct{}](ctx, noNativeRetries, a.ProjectionUpdateRetry, id, (*run.RetryStatus)(nil)).Get(ctx); clearErr != nil {
417
+ return zero, clearErr
418
+ }
419
+ }
420
+ if blocked {
421
+ // External state is compatible again: leave blocked.
422
+ _, _ = scheduleState(ctx, a, id, run.StateWaiting, "")
423
+ }
424
+ return result, nil
425
+ }
426
+ if ctx.Err() != nil {
427
+ return zero, ctx.Err()
428
+ }
429
+ f := classifyActivityError(err)
430
+ if f.Kind == retry.Conflict {
431
+ // Mark blocked, keep retrying on the capped schedule.
432
+ _, _ = scheduleState(ctx, a, id, run.StateBlocked, f.Message)
433
+ blocked = true
434
+ }
435
+ delay := retry.DefaultBackoffPolicy.Delay(attempt, mustJitter(ctx))
436
+ logRetry(ctx, work, node, f, delay)
437
+ nextRetry := goworkflow.Now(ctx).UTC().Add(delay)
438
+ status := &run.RetryStatus{
439
+ Attempt: attempt + 1, LastError: sanitizeRetryMessage(f.Message), NextRetryAt: nextRetry,
440
+ }
441
+ if _, projectionErr := goworkflow.ExecuteActivity[struct{}](ctx, noNativeRetries, a.ProjectionUpdateRetry, id, status).Get(ctx); projectionErr != nil {
442
+ return zero, projectionErr
443
+ }
444
+ if _, err := goworkflow.ScheduleTimer(ctx, delay).Get(ctx); err != nil {
445
+ return zero, err
446
+ }
447
+ attempt++
448
+ }
449
+ }
450
+
451
+ // logRetry emits the 9.6 retry-classification info line, replay-safe.
452
+ // Attrs come from the always-known run.Work value carried by the caller,
453
+ // so ticket/repo/workflow are present even if the projection is briefly
454
+ // unreadable; node is included whenever the retry runs inside a node body.
455
+ func logRetry(ctx goworkflow.Context, work run.Work, node string, f retry.Failure, delay time.Duration) {
456
+ kind := string(f.Kind)
457
+ delayMs := delay.Milliseconds()
458
+ msg := sanitizeRetryMessage(f.Message)
459
+ attrs := []any{
460
+ "ticket", work.Parent.Key,
461
+ "runID", string(work.RunID),
462
+ "repo", work.Repo,
463
+ "workflow", work.Workflow,
464
+ "kind", kind,
465
+ "delayMs", delayMs,
466
+ "error", msg,
467
+ }
468
+ if node != "" {
469
+ attrs = append(attrs, "node", node)
470
+ }
471
+ _, _ = goworkflow.SideEffect(ctx, func(goworkflow.Context) struct{} {
472
+ slog.Info("retry scheduled", attrs...)
473
+ return struct{}{}
474
+ }).Get(ctx)
475
+ }
476
+
477
+ // sanitizeRetryMessage strips each "[...]: " argv-listing span that adapter
478
+ // wrappers embed (acli [args...]:, orca [args...]:) so the info-level retry
479
+ // record never leaks --body/--command/JQL/prompt payloads. Keeps the
480
+ // surrounding wrap context and trailing exit status / stderr fragment that
481
+ // carry the failure reason. The original error returned to callers is
482
+ // unchanged; this sanitizes only the logged string.
483
+ func sanitizeRetryMessage(s string) string {
484
+ for {
485
+ i := strings.Index(s, "[")
486
+ if i < 0 {
487
+ return s
488
+ }
489
+ j := strings.Index(s[i:], "]: ")
490
+ if j < 0 {
491
+ return s
492
+ }
493
+ // Remove the "[...]: " span, keeping any preceding text (minus a
494
+ // trailing space) and everything after.
495
+ s = strings.TrimSuffix(s[:i], " ") + s[i+j+3:]
496
+ }
497
+ }
498
+
499
+ // classifyActivityError classifies an activity error after its durable
500
+ // round-trip. The engine serializes error causes, so the concrete
501
+ // retry conflict wrapper does not survive; its stable type name does.
502
+ func classifyActivityError(err error) retry.Failure {
503
+ if err == nil {
504
+ return retry.Failure{}
505
+ }
506
+ if isConflictRoundTrip(err) {
507
+ return retry.Failure{Kind: retry.Conflict, Message: err.Error()}
508
+ }
509
+ return retry.Classify(err)
510
+ }
511
+
512
+ // isConflictRoundTrip reports whether err (or any serialized cause) carries
513
+ // the conflict marker type name produced by retry.ConflictError.
514
+ func isConflictRoundTrip(err error) bool {
515
+ for e := err; e != nil; {
516
+ v := reflect.ValueOf(e)
517
+ if v.Kind() == reflect.Ptr {
518
+ v = v.Elem()
519
+ }
520
+ if v.IsValid() && v.Kind() == reflect.Struct {
521
+ if f := v.FieldByName("Type"); f.IsValid() && f.Kind() == reflect.String && f.String() == "conflictError" {
522
+ return true
523
+ }
524
+ }
525
+ u, ok := e.(interface{ Unwrap() error })
526
+ if !ok {
527
+ return false
528
+ }
529
+ e = u.Unwrap()
530
+ }
531
+ return false
532
+ }
533
+
534
+ // scheduleState records a state change via the projection activity.
535
+ func scheduleState(ctx goworkflow.Context, a *Activities, id run.ID, state run.State, msg string) (struct{}, error) {
536
+ return goworkflow.ExecuteActivity[struct{}](ctx, noNativeRetries, a.ProjectionUpdateState, id, state, msg, (*time.Time)(nil)).Get(ctx)
537
+ }
538
+
539
+ func mustJitter(ctx goworkflow.Context) float64 {
540
+ v, err := jitter(ctx)
541
+ if err != nil {
542
+ return 0
543
+ }
544
+ return v
545
+ }
546
+
547
+ // cancelCleanup runs cancellation cleanup on a disconnected workflow
548
+ // context: close run-owned terminals (preserving the workspace), write the
549
+ // parent cancellation comment with the stable marker, and mark the run
550
+ // canceled. No rollback/compensation ever runs.
551
+ func (a *Activities) cancelCleanup(ctx goworkflow.Context, work run.Work, repoPath, reason string) error {
552
+ dctx := goworkflow.NewDisconnectedContext(ctx)
553
+ if _, err := retryLoop(dctx, work.RunID, a, work, "",
554
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
555
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
556
+ a.FinalizeNodeRuntimes, work, repoPath, work.Runtime)
557
+ }); err != nil {
558
+ return err
559
+ }
560
+ if _, err := retryLoop(dctx, work.RunID, a, work, "",
561
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
562
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, work.Repo, run.CommentWork{
563
+ RunID: work.RunID,
564
+ Item: task.Target{Parent: work.Parent},
565
+ Body: "Run canceled: " + reason,
566
+ Marker: run.CancellationMarker(work.RunID),
567
+ })
568
+ }); err != nil {
569
+ return err
570
+ }
571
+ if _, err := retryLoop(dctx, work.RunID, a, work, "",
572
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
573
+ now := goworkflow.Now(dctx).UTC()
574
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ProjectionUpdateState, work.RunID, run.StateCanceled, "", &now)
575
+ }); err != nil {
576
+ return err
577
+ }
578
+ return nil
579
+ }
580
+
581
+ // nextStepsText renders the legal next steps with their when explanations
582
+ // for nudge templates.
583
+ func nextStepsText(routes []workflow.Route) string {
584
+ var b strings.Builder
585
+ for i, r := range routes {
586
+ if i > 0 {
587
+ b.WriteString("; ")
588
+ }
589
+ b.WriteString(r.Target)
590
+ if r.When != "" {
591
+ b.WriteString(" (when: " + r.When + ")")
592
+ }
593
+ }
594
+ return b.String()
595
+ }
596
+
597
+ func renderSummary(r workflow.Report) string {
598
+ return fmt.Sprintf("SUMMARY\nCOMPLETED:\n%s\n\nCOMMITS:\n%s\n\nNOT COMPLETED:\n%s\n\nISSUES DISCOVERED:\n%s\n\nVERIFICATION:\n%s\n\nNOTES:\n%s\n",
599
+ r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
600
+ }
601
+
602
+ func renderFeedback(source string, r workflow.Report) string {
603
+ return fmt.Sprintf("Feedback from %s\nCOMMITS:\n%s\n\nREASON FOR NEXT STEP:\n%s\n\nREQUIRED ACTIONS:\n%s\n\nRELEVANT CONTEXT:\n%s\n\nEXPECTED RESULT:\n%s\n",
604
+ source, r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
605
+ }