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