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.
- package/README.md +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- 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
|
+
}
|