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.
- package/README.md +20 -15
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/main.go +94 -13
- package/cmd/relay-flow/scenario_test.go +19 -2
- package/cmd/relay-flow/serve.go +98 -19
- package/cmd/relay-flow/serve_recovery_test.go +100 -0
- package/cmd/relay-flow/temporal_init.go +170 -0
- package/cmd/relay-flow/temporal_init_test.go +217 -0
- package/cmd/relay-flow/temporal_report_test.go +733 -0
- package/examples/config-reference.yaml +2 -2
- package/examples/minimal-beads-task-workflow.yaml +2 -1
- package/examples/workflow-reference.yaml +2 -1
- package/go.mod +37 -16
- package/go.sum +129 -61
- package/internal/config/machine.go +33 -1
- package/internal/config/machine_test.go +76 -0
- package/internal/execution/goworkflows/engine.go +13 -38
- package/internal/execution/goworkflows/projection.go +47 -464
- package/internal/execution/projection/projection.go +867 -0
- package/internal/execution/projection/projection_test.go +347 -0
- package/internal/execution/temporal/activities.go +567 -0
- package/internal/execution/temporal/engine.go +384 -0
- package/internal/execution/temporal/engine_test.go +277 -0
- package/internal/execution/temporal/interpreter.go +736 -0
- package/internal/execution/temporal/operations.go +455 -0
- package/internal/execution/temporal/operations_test.go +101 -0
- package/internal/execution/temporal/recovery.go +194 -0
- package/internal/execution/temporal/recovery_runtime.go +41 -0
- package/internal/execution/temporal/recovery_test.go +102 -0
- package/internal/execution/temporal/snapshot_restart_test.go +72 -0
- package/internal/execution/temporal/spike_test.go +934 -0
- package/internal/execution/temporal/visibility_lag_test.go +415 -0
- package/internal/harness/opencode/opencode.go +3 -1
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +49 -46
- package/internal/harness/pi/pi_test.go +26 -10
- package/internal/harness/pi/prompt_test.go +30 -1
- package/internal/harness/pi/validation_test.go +27 -51
- package/internal/runner/herdr/herdr.go +14 -0
- package/internal/runner/herdr/herdr_test.go +20 -0
- package/internal/runner/orca/orca.go +33 -0
- package/internal/runner/orca/orca_test.go +33 -4
- package/internal/runner/runner.go +8 -0
- 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
|
+
}
|