relay-flow 0.3.9-alpha → 0.3.11-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +30 -34
  2. package/cmd/relay-flow/commands_test.go +449 -23
  3. package/cmd/relay-flow/main.go +207 -37
  4. package/cmd/relay-flow/observability_test.go +11 -0
  5. package/cmd/relay-flow/onboarding.go +1 -1
  6. package/cmd/relay-flow/onboarding_test.go +1 -1
  7. package/cmd/relay-flow/scenario_test.go +26 -10
  8. package/cmd/relay-flow/serve.go +27 -1
  9. package/examples/config-reference.yaml +23 -19
  10. package/examples/workflow-reference.yaml +3 -0
  11. package/internal/execution/goworkflows/activities.go +13 -22
  12. package/internal/execution/goworkflows/engine_test.go +17 -3
  13. package/internal/execution/goworkflows/fakes_test.go +20 -0
  14. package/internal/execution/goworkflows/interpreter.go +25 -11
  15. package/internal/execution/goworkflows/mailbox_test.go +28 -1
  16. package/internal/execution/goworkflows/ownership_test.go +259 -0
  17. package/internal/execution/goworkflows/recovery_test.go +81 -1
  18. package/internal/execution/temporal/activities.go +13 -22
  19. package/internal/execution/temporal/compact_mailbox_test.go +56 -0
  20. package/internal/execution/temporal/interpreter.go +16 -3
  21. package/internal/execution/temporal/recovery.go +68 -36
  22. package/internal/harness/harness.go +23 -10
  23. package/internal/harness/opencode/opencode.go +22 -4
  24. package/internal/harness/opencode/opencode_test.go +41 -1
  25. package/internal/harness/opencode/repo_setup.go +1 -1
  26. package/internal/harness/opencode/task_env_test.go +2 -2
  27. package/internal/harness/pi/pi.go +21 -2
  28. package/internal/harness/pi/pi_test.go +7 -4
  29. package/internal/harness/pi/prompt_test.go +13 -5
  30. package/internal/harness/pi/task_env_test.go +18 -2
  31. package/internal/recover/recover.go +30 -1
  32. package/internal/repo/binding_test.go +63 -0
  33. package/internal/repo/repo.go +47 -6
  34. package/internal/router/router.go +39 -5
  35. package/internal/router/router_test.go +67 -1
  36. package/internal/run/manager.go +90 -3
  37. package/internal/run/run_manager_test.go +74 -0
  38. package/internal/server/api_test.go +13 -0
  39. package/internal/server/client.go +7 -0
  40. package/internal/server/fixture_test.go +5 -0
  41. package/internal/server/server.go +32 -3
  42. package/internal/task/beads/beads.go +201 -3
  43. package/internal/task/beads/beads_test.go +172 -0
  44. package/internal/task/jira/effects_test.go +86 -0
  45. package/internal/task/jira/filters_test.go +103 -0
  46. package/internal/task/jira/helpers_test.go +11 -2
  47. package/internal/task/jira/jira.go +244 -15
  48. package/internal/task/jira/normalize.go +30 -15
  49. package/internal/task/jira/rest/client.go +17 -2
  50. package/internal/task/jira/templates_test.go +38 -2
  51. package/internal/task/jira/transition_defaults_test.go +3 -1
  52. package/internal/task/task.go +67 -0
  53. package/internal/workflow/report.go +10 -0
  54. package/internal/workflow/report_test.go +16 -0
  55. package/internal/workflow/workflow.go +24 -0
  56. package/internal/workflow/workflow_test.go +58 -1
  57. package/package.json +1 -1
@@ -72,13 +72,16 @@ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
72
72
  t.Fatal(err)
73
73
  }
74
74
  want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval."
75
+ if taskSystem == "jira" {
76
+ want = "Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
77
+ }
75
78
  if prompt != want {
76
79
  t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
77
80
  }
78
- if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
81
+ if taskSystem != "jira" && (strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask")) {
79
82
  t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
80
83
  }
81
- if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
84
+ if strings.Contains(prompt, "STATUS:") || (taskSystem != "jira" && strings.Contains(prompt, node.Description)) {
82
85
  t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
83
86
  }
84
87
  }
@@ -467,6 +470,7 @@ func TestTransitionOrdering(t *testing.T) {
467
470
  log := newEventLog()
468
471
  sys := newFakeTaskSystem(log)
469
472
  fr := newFakeRunner(log)
473
+ fh := newFakeHarness(log)
470
474
  wf := workflow.Workflow{
471
475
  Name: "reviewFlow", Repos: []string{"payments"},
472
476
  Nodes: map[string]workflow.Node{
@@ -485,7 +489,7 @@ func TestTransitionOrdering(t *testing.T) {
485
489
  },
486
490
  }
487
491
  engine := newEngine(t, goworkflows.Dependencies{
488
- Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
492
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
489
493
  })
490
494
  rid, _ := startRun(engine, wf)
491
495
  waitFor(t, 10*time.Second, func() bool {
@@ -510,6 +514,16 @@ func TestTransitionOrdering(t *testing.T) {
510
514
  t.Fatalf("HITL status was not set before terminal start; events=%v", events)
511
515
  }
512
516
 
517
+ var sawReviewFeedback bool
518
+ for _, call := range fh.promptCalls() {
519
+ if call.Data.Node == "review" && call.Data.PreviousFeedback == "review it" {
520
+ sawReviewFeedback = true
521
+ }
522
+ }
523
+ if !sawReviewFeedback {
524
+ t.Fatalf("next node prompt did not receive selected feedback: %+v", fh.promptCalls())
525
+ }
526
+
513
527
  // Exact cross-primitive order, observed through the fake-adapter and fake-
514
528
  // runner call logs (the settled observation seam):
515
529
  // summary(current) -> feedback(selected next) -> CompleteMailbox(current)
@@ -73,6 +73,7 @@ type fakeTaskSystem struct {
73
73
  labels map[string][]string // mailbox key -> labels
74
74
  specs []task.MailboxSpec
75
75
  comments []recordedComment
76
+ hasCommentN int
76
77
  resets []string
77
78
  renderText func(task.TextKind, task.TextData) (string, error)
78
79
 
@@ -115,6 +116,18 @@ func (s *fakeTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool
115
116
  return func(task.Ticket) bool { return true }, nil
116
117
  }
117
118
 
119
+ func (s *fakeTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
120
+ return func(task.Ticket) bool { return true }, nil
121
+ }
122
+
123
+ func (s *fakeTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
124
+ return nil
125
+ }
126
+
127
+ func (s *fakeTaskSystem) ClaimIfOwned(ctx context.Context, ref task.TicketRef, workflowName string, _ config.RawValues) error {
128
+ return s.Claim(ctx, ref, workflowName)
129
+ }
130
+
118
131
  func (s *fakeTaskSystem) Claim(_ context.Context, ref task.TicketRef, wf string) error {
119
132
  s.log.add("claim:" + ref.Key + ":" + wf)
120
133
  return nil
@@ -208,6 +221,7 @@ func (s *fakeTaskSystem) CompleteMailbox(_ context.Context, mb task.Mailbox) err
208
221
 
209
222
  func (s *fakeTaskSystem) HasComment(_ context.Context, target task.Target, marker string) (bool, error) {
210
223
  s.mu.Lock()
224
+ s.hasCommentN++
211
225
  defer s.mu.Unlock()
212
226
  for _, c := range s.comments {
213
227
  if c.Marker == marker {
@@ -267,6 +281,12 @@ func (s *fakeTaskSystem) ResetForRecovery(_ context.Context, parent task.TicketR
267
281
  return nil
268
282
  }
269
283
 
284
+ func (s *fakeTaskSystem) hasCommentCount() int {
285
+ s.mu.Lock()
286
+ defer s.mu.Unlock()
287
+ return s.hasCommentN
288
+ }
289
+
270
290
  func (s *fakeTaskSystem) commentBodies(key string) []recordedComment {
271
291
  s.mu.Lock()
272
292
  defer s.mu.Unlock()
@@ -205,6 +205,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
205
205
  }
206
206
 
207
207
  current := target
208
+ previousFeedback := ""
208
209
  seenReportIDs := map[string]bool{}
209
210
  lastStepByNode := map[string]int64{}
210
211
  lastDepthByNode := map[string]int{}
@@ -292,20 +293,22 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
292
293
  Ticket: start.Ticket.Key,
293
294
  Node: current,
294
295
  NodeType: node.Type,
296
+ AutoReject: node.AutoReject,
295
297
  Agent: node.Agent,
296
298
  Title: title,
297
299
  NudgePrompt: node.NudgePrompt,
298
300
  PromptData: harness.PromptData{
299
- TaskSystem: a.TaskSystem,
300
- Ticket: start.Ticket.Key,
301
- Workflow: wf.Name,
302
- Repo: start.Repo,
303
- Node: current,
304
- NodeType: node.Type,
305
- Agent: node.Agent,
306
- NodeDescription: node.Description,
307
- NextSteps: nextStepsText(nextSteps),
308
- Mailbox: mb.Key,
301
+ TaskSystem: a.TaskSystem,
302
+ Ticket: start.Ticket.Key,
303
+ Workflow: wf.Name,
304
+ Repo: start.Repo,
305
+ Node: current,
306
+ NodeType: node.Type,
307
+ Agent: node.Agent,
308
+ NodeDescription: node.Description,
309
+ NextSteps: nextStepsText(nextSteps),
310
+ Mailbox: mb.Key,
311
+ PreviousFeedback: previousFeedback,
309
312
  },
310
313
  NextSteps: nextSteps,
311
314
  }
@@ -394,7 +397,9 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
394
397
  stepMessage := report.Summary.Completed
395
398
  if report.Status == workflow.OutcomeFailure {
396
399
  stepStatus = run.StepFailed
397
- stepMessage = report.Summary.IssuesDiscovered
400
+ if report.Summary.IssuesDiscovered != workflow.None {
401
+ stepMessage = report.Summary.IssuesDiscovered
402
+ }
398
403
  }
399
404
  stepFinished := goworkflow.Now(ctx).UTC()
400
405
  if _, err := retryLoop(ctx, start.ID, a, work, current,
@@ -464,6 +469,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
464
469
  return err
465
470
  }
466
471
 
472
+ previousFeedback = report.Feedback.RequiredActions
467
473
  current = next
468
474
  }
469
475
 
@@ -760,11 +766,19 @@ func nextStepsText(routes []workflow.Route) string {
760
766
  }
761
767
 
762
768
  func renderSummaryReport(r workflow.Report) string {
769
+ if r.Summary.Commits == workflow.None && r.Summary.NotCompleted == workflow.None &&
770
+ r.Summary.IssuesDiscovered == workflow.None && r.Summary.Verification == workflow.None && r.Summary.Notes == workflow.None {
771
+ return r.Summary.Completed
772
+ }
763
773
  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",
764
774
  r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
765
775
  }
766
776
 
767
777
  func renderFeedbackReport(r workflow.Report) string {
778
+ if r.Summary.Commits == workflow.None && r.Feedback.ReasonForNextStep == workflow.None &&
779
+ r.Feedback.RelevantContext == workflow.None && r.Feedback.ExpectedResult == workflow.None {
780
+ return r.Feedback.RequiredActions
781
+ }
768
782
  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",
769
783
  r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
770
784
  }
@@ -41,6 +41,33 @@ func threeNodeWorkflow() workflow.Workflow {
41
41
  }
42
42
  }
43
43
 
44
+ func TestCompactMailboxTemplateDoesNotAppendGenericInstructions(t *testing.T) {
45
+ wf := threeNodeWorkflow()
46
+ sys := newFakeTaskSystem(newEventLog())
47
+ sys.renderText = func(_ task.TextKind, data task.TextData) (string, error) {
48
+ return data.Ticket + " / " + data.Node + " — " + data.NodeType + " — " + data.Agent +
49
+ "\n" + data.NodeDescription + "\nSuccess routes:\n" + data.SuccessRoutes +
50
+ "\nFailure routes:\n" + data.FailureRoutes + "\n" + data.Report, nil
51
+ }
52
+ specs, err := goworkflows.RenderMailboxSpecs(sys, run.Work{
53
+ Parent: task.TicketRef{Key: "PAY-101"}, Repo: "payments", Workflow: wf.Name,
54
+ }, &wf)
55
+ if err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ for _, spec := range specs {
59
+ for _, required := range []string{"PAY-101 / " + spec.Node, wf.Nodes[spec.Node].Description,
60
+ "Success routes:", "Failure routes:", " — when: ", workflow.ReportFormat} {
61
+ if !strings.Contains(spec.Description, required) {
62
+ t.Fatalf("mailbox %s description lacks %q: %q", spec.Node, required, spec.Description)
63
+ }
64
+ }
65
+ if strings.Contains(spec.Description, "Required report format:") {
66
+ t.Fatalf("mailbox %s appended generic instructions: %q", spec.Node, spec.Description)
67
+ }
68
+ }
69
+ }
70
+
44
71
  func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
45
72
  log := newEventLog()
46
73
  sys := newFakeTaskSystem(log)
@@ -107,7 +134,7 @@ func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
107
134
  t.Fatalf("%s description lacks route explanation %q: %q", node, when, d)
108
135
  }
109
136
  }
110
- for _, required := range []string{"Required report format:", "STATUS:", "COMMITS:", "FEEDBACK:"} {
137
+ for _, required := range []string{"Required report format:", "STATUS:", "NEXT STEP:", "SUMMARY:", "FEEDBACK:"} {
111
138
  if !strings.Contains(d, required) {
112
139
  t.Fatalf("%s description lacks %q: %q", node, required, d)
113
140
  }
@@ -0,0 +1,259 @@
1
+ package goworkflows_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "sync"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/config"
11
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
12
+ "github.com/rajpopat27/relay-flow/internal/identity"
13
+ recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
14
+ "github.com/rajpopat27/relay-flow/internal/router"
15
+ "github.com/rajpopat27/relay-flow/internal/run"
16
+ "github.com/rajpopat27/relay-flow/internal/task"
17
+ "github.com/rajpopat27/relay-flow/internal/workflow"
18
+ )
19
+
20
+ // ownershipCompositionState models the provider-owned labels and current
21
+ // assignee shared by two independent relay-flow databases. The adapter fake
22
+ // persists a durable wf-owner marker when the first server claims the ticket.
23
+ type ownershipCompositionState struct {
24
+ mu sync.Mutex
25
+ assignee string
26
+ labels []string
27
+ }
28
+
29
+ type ownershipCompositionTaskSystem struct {
30
+ *fakeTaskSystem
31
+ owner string
32
+ state *ownershipCompositionState
33
+ }
34
+
35
+ func newOwnershipCompositionTaskSystem(owner string, state *ownershipCompositionState, log *eventLog) *ownershipCompositionTaskSystem {
36
+ return &ownershipCompositionTaskSystem{
37
+ fakeTaskSystem: newFakeTaskSystem(log), owner: owner, state: state,
38
+ }
39
+ }
40
+
41
+ func (s *ownershipCompositionTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
42
+ s.state.mu.Lock()
43
+ defer s.state.mu.Unlock()
44
+ claims := make([]string, 0, 1)
45
+ for _, label := range s.state.labels {
46
+ if len(label) > len("wf:") && label[:len("wf:")] == "wf:" {
47
+ claims = append(claims, label)
48
+ }
49
+ }
50
+ return []task.Ticket{{
51
+ ID: "1", Key: "PAY-101", Title: "ownership composition",
52
+ WorkflowClaims: claims,
53
+ Fields: map[string]any{
54
+ "assignee": s.state.assignee,
55
+ "labels": append([]string(nil), s.state.labels...),
56
+ },
57
+ }}, nil
58
+ }
59
+
60
+ func (s *ownershipCompositionTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
61
+ return func(ticket task.Ticket) bool {
62
+ assignee, _ := ticket.Fields["assignee"].(string)
63
+ return assignee == s.owner
64
+ }, nil
65
+ }
66
+
67
+ func (s *ownershipCompositionTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
68
+ return func(ticket task.Ticket) bool {
69
+ if len(ticket.WorkflowClaims) != 1 {
70
+ return false
71
+ }
72
+ assignee, _ := ticket.Fields["assignee"].(string)
73
+ if assignee != s.owner {
74
+ return false
75
+ }
76
+ want := "wf-owner:" + ticket.WorkflowClaims[0][len("wf:"):] + ":" + s.owner
77
+ labels, _ := ticket.Fields["labels"].([]string)
78
+ for _, label := range labels {
79
+ if label == want {
80
+ return true
81
+ }
82
+ }
83
+ return false
84
+ }, nil
85
+ }
86
+
87
+ func (s *ownershipCompositionTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
88
+ s.state.mu.Lock()
89
+ defer s.state.mu.Unlock()
90
+ if s.hasOwnerMarkerLocked("ownershipFlow") {
91
+ return nil
92
+ }
93
+ return &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "ownershipFlow"}
94
+ }
95
+
96
+ func (s *ownershipCompositionTaskSystem) BackfillClaimOwner(context.Context, task.TicketRef, string, config.RawValues) error {
97
+ s.state.mu.Lock()
98
+ defer s.state.mu.Unlock()
99
+ if !containsLabel(s.state.labels, "wf:ownershipFlow") {
100
+ return &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "ownershipFlow"}
101
+ }
102
+ marker := "wf-owner:ownershipFlow:" + s.owner
103
+ if !containsLabel(s.state.labels, marker) {
104
+ s.state.labels = append(s.state.labels, marker)
105
+ }
106
+ return nil
107
+ }
108
+
109
+ func (s *ownershipCompositionTaskSystem) ClaimIfOwned(ctx context.Context, ticket task.TicketRef, workflowName string, _ config.RawValues) error {
110
+ s.state.mu.Lock()
111
+ defer s.state.mu.Unlock()
112
+ if s.state.assignee != s.owner {
113
+ return &task.OwnershipMismatchError{Ticket: ticket.Key, Workflow: workflowName}
114
+ }
115
+ ownerMarker := "wf-owner:" + workflowName + ":" + s.owner
116
+ claim := "wf:" + workflowName
117
+ for _, label := range s.state.labels {
118
+ if label == ownerMarker && containsLabel(s.state.labels, claim) {
119
+ return nil
120
+ }
121
+ if len(label) > len("wf:") && label[:len("wf:")] == "wf:" {
122
+ return &task.OwnershipMismatchError{Ticket: ticket.Key, Workflow: workflowName}
123
+ }
124
+ }
125
+ s.state.labels = append(s.state.labels, ownerMarker, claim)
126
+ return nil
127
+ }
128
+
129
+ func (s *ownershipCompositionTaskSystem) hasOwnerMarkerLocked(workflowName string) bool {
130
+ return containsLabel(s.state.labels, "wf-owner:"+workflowName+":"+s.owner)
131
+ }
132
+
133
+ func containsLabel(labels []string, want string) bool {
134
+ for _, label := range labels {
135
+ if label == want {
136
+ return true
137
+ }
138
+ }
139
+ return false
140
+ }
141
+
142
+ func TestExplicitLegacyBackfillPreservesClaimAndEnablesRoutingAndRecovery(t *testing.T) {
143
+ state := &ownershipCompositionState{assignee: "alice", labels: []string{"wf:ownershipFlow"}}
144
+ log := newEventLog()
145
+ sys := newOwnershipCompositionTaskSystem("alice", state, log)
146
+ reg := repoRegistryWith("payments", sys)
147
+ wf := linearWorkflow(false)
148
+ wf.Name = "ownershipFlow"
149
+ wf.TaskConfig = config.RawValues{"filters": map[string]any{"assignees": []any{"currentUser()"}}}
150
+ if err := reg.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
151
+ t.Fatal(err)
152
+ }
153
+ workflowReg := &workflow.Registry{}
154
+ workflowReg.Replace(&wf)
155
+ fr := newFakeRunner(log)
156
+ engine := newEngine(t, goworkflows.Dependencies{Repos: reg, Runner: fr, Harness: newFakeHarness(log)})
157
+ manager := &run.RunManager{Executor: engine, Runs: engine, Repos: reg, Workflows: workflowReg}
158
+ if err := manager.BackfillClaimOwner(context.Background(), "payments", "PAY-101", "ownershipFlow"); err != nil {
159
+ t.Fatalf("explicit backfill failed: %v", err)
160
+ }
161
+ state.mu.Lock()
162
+ labels := append([]string(nil), state.labels...)
163
+ state.mu.Unlock()
164
+ if !containsLabel(labels, "wf:ownershipFlow") || !containsLabel(labels, "wf-owner:ownershipFlow:alice") || len(labels) != 2 {
165
+ t.Fatalf("backfill labels = %v, want preserved claim plus one provenance marker", labels)
166
+ }
167
+ rp, _ := reg.Get("payments")
168
+ ticket, _ := sys.Poll(context.Background())
169
+ if _, err := router.ResolveWorkflow(rp, ticket[0]); err != nil {
170
+ t.Fatalf("backfilled claim did not route normally: %v", err)
171
+ }
172
+ specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
173
+ return goworkflows.RenderMailboxSpecs(system, work, w)
174
+ }
175
+ if err := recoverpkg.FromTaskSystem(context.Background(), reg, fr, manager, specsFor); err != nil {
176
+ t.Fatalf("backfilled claim did not recover: %v", err)
177
+ }
178
+ if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "ownershipFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 1 {
179
+ t.Fatalf("recovered runs = %v, %v; want one run", runs, err)
180
+ }
181
+ }
182
+
183
+ func TestIndependentRunDatabasesCannotTakeOverReassignedClaim(t *testing.T) {
184
+ state := &ownershipCompositionState{assignee: "alice"}
185
+ logA, logB := newEventLog(), newEventLog()
186
+ sysA := newOwnershipCompositionTaskSystem("alice", state, logA)
187
+ sysB := newOwnershipCompositionTaskSystem("bob", state, logB)
188
+ regA := repoRegistryWith("payments", sysA)
189
+ regB := repoRegistryWith("payments", sysB)
190
+ wf := linearWorkflow(false)
191
+ wf.Name = "ownershipFlow"
192
+ wf.TaskConfig = config.RawValues{"filters": map[string]any{
193
+ "assignees": []any{"currentUser()"},
194
+ }}
195
+ if err := regA.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
196
+ t.Fatal(err)
197
+ }
198
+ if err := regB.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
199
+ t.Fatal(err)
200
+ }
201
+
202
+ engineA := newEngine(t, goworkflows.Dependencies{
203
+ Repos: regA, Runner: newFakeRunner(logA), Harness: newFakeHarness(logA),
204
+ })
205
+ engineB := newEngine(t, goworkflows.Dependencies{
206
+ Repos: regB, Runner: newFakeRunner(logB), Harness: newFakeHarness(logB),
207
+ })
208
+ managerA := &run.RunManager{Executor: engineA, Runs: engineA}
209
+ managerB := &run.RunManager{Executor: engineB, Runs: engineB}
210
+
211
+ ticketA, err := sysA.Poll(context.Background())
212
+ if err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ rpA, _ := regA.Get("payments")
216
+ resolvedA, err := router.ResolveWorkflow(rpA, ticketA[0])
217
+ if err != nil {
218
+ t.Fatalf("owner A route failed: %v", err)
219
+ }
220
+ if err := managerA.EnsureRun(context.Background(), rpA, resolvedA, ticketA[0]); err != nil {
221
+ t.Fatalf("owner A EnsureRun failed: %v", err)
222
+ }
223
+ waitFor(t, 10*time.Second, func() bool {
224
+ current, err := engineA.GetRun(context.Background(), identity.NewRunID("payments", "ownershipFlow", "PAY-101"))
225
+ return err == nil && current.State != run.StateCompleted && current.State != run.StateCanceled
226
+ })
227
+
228
+ state.mu.Lock()
229
+ state.assignee = "bob"
230
+ state.mu.Unlock()
231
+ ticketB, err := sysB.Poll(context.Background())
232
+ if err != nil {
233
+ t.Fatal(err)
234
+ }
235
+ rpB, _ := regB.Get("payments")
236
+ if _, err := router.ResolveWorkflow(rpB, ticketB[0]); !errors.Is(err, router.ErrClaimOwnerMismatch) {
237
+ t.Fatalf("owner B route error = %v, want claim-owner-mismatch", err)
238
+ }
239
+ if err := managerB.EnsureRun(context.Background(), rpB, &wf, ticketB[0]); !errors.Is(err, task.ErrOwnershipMismatch) {
240
+ t.Fatalf("owner B EnsureRun error = %v, want ownership mismatch", err)
241
+ }
242
+ if runs, err := engineB.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "ownershipFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
243
+ t.Fatalf("owner B database runs = %v, %v; want none", runs, err)
244
+ }
245
+
246
+ // Reassignment is a current-owner mismatch for both servers. The
247
+ // original durable run remains in database A, but neither server calls
248
+ // EnsureRun to reconcile or create execution state after the mismatch.
249
+ ticketAAfterReassignment, _ := sysA.Poll(context.Background())
250
+ if _, err := router.ResolveWorkflow(rpA, ticketAAfterReassignment[0]); !errors.Is(err, router.ErrClaimOwnerMismatch) {
251
+ t.Fatalf("owner A route after reassignment = %v, want claim-owner-mismatch", err)
252
+ }
253
+ if err := managerA.EnsureRun(context.Background(), rpA, &wf, ticketAAfterReassignment[0]); !errors.Is(err, task.ErrOwnershipMismatch) {
254
+ t.Fatalf("owner A EnsureRun after reassignment = %v, want ownership mismatch", err)
255
+ }
256
+ if runs, err := engineA.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "ownershipFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 1 {
257
+ t.Fatalf("owner A database runs after reassignment = %v, %v; want one existing run", runs, err)
258
+ }
259
+ }
@@ -10,6 +10,7 @@ import (
10
10
  "time"
11
11
 
12
12
  "github.com/cschleiden/go-workflows/backend/history"
13
+ "github.com/rajpopat27/relay-flow/internal/config"
13
14
  "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
14
15
  "github.com/rajpopat27/relay-flow/internal/identity"
15
16
  recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
@@ -1358,7 +1359,7 @@ func TestDatabaseFileIsOwnerOnly(t *testing.T) {
1358
1359
  // task.System/runner.Runner plus the real engine and the real RunManager.
1359
1360
  // Rewritten in 6.3 from a test-local copy so the production wiring is
1360
1361
  // actually covered.
1361
- func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTaskSystem, fr *fakeRunner, wf workflow.Workflow) error {
1362
+ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys task.System, fr *fakeRunner, wf workflow.Workflow) error {
1362
1363
  reg := repo.NewRegistry()
1363
1364
  reg.Replace(&repo.Repo{
1364
1365
  Name: "payments",
@@ -1376,6 +1377,85 @@ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTa
1376
1377
  return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
1377
1378
  }
1378
1379
 
1380
+ type mismatchedRecoveryTaskSystem struct{ *fakeTaskSystem }
1381
+
1382
+ type legacyRecoveryTaskSystem struct{ *fakeTaskSystem }
1383
+
1384
+ func (s *legacyRecoveryTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
1385
+ return func(task.Ticket) bool { return false }, nil
1386
+ }
1387
+
1388
+ func (s *mismatchedRecoveryTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
1389
+ // Let the router resolve the claim; the current-provider read below is the
1390
+ // mismatch gate being exercised by this recovery regression.
1391
+ return func(task.Ticket) bool { return true }, nil
1392
+ }
1393
+
1394
+ func (s *mismatchedRecoveryTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
1395
+ return &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "basicFlow"}
1396
+ }
1397
+
1398
+ func TestServeRecoverSkipsOwnerMismatchBeforeMutations(t *testing.T) {
1399
+ log := newEventLog()
1400
+ base := newFakeTaskSystem(log)
1401
+ sys := &mismatchedRecoveryTaskSystem{fakeTaskSystem: base}
1402
+ sys.parentsToRecover = []task.Ticket{{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}}
1403
+ fr := newFakeRunner(log)
1404
+ engine := newEngine(t, goworkflows.Dependencies{
1405
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
1406
+ })
1407
+ wf := linearWorkflow(false)
1408
+ reg := repo.NewRegistry()
1409
+ reg.Replace(&repo.Repo{Name: "payments", Path: "/srv/payments", TaskSystem: sys})
1410
+ if err := reg.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
1411
+ t.Fatal(err)
1412
+ }
1413
+ rm := &run.RunManager{Executor: engine, Runs: engine}
1414
+ specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
1415
+ return goworkflows.RenderMailboxSpecs(system, work, w)
1416
+ }
1417
+ if err := recoverpkg.FromTaskSystem(context.Background(), reg, fr, rm, specsFor); err != nil {
1418
+ t.Fatalf("recovery failed on owner mismatch: %v", err)
1419
+ }
1420
+ if len(fr.closedRun) != 0 {
1421
+ t.Fatalf("CloseTerminals calls = %v, want none", fr.closedRun)
1422
+ }
1423
+ if base.hasCommentCount() != 0 {
1424
+ t.Fatalf("HasComment calls = %d, want none before owner rejection", base.hasCommentCount())
1425
+ }
1426
+ if len(base.resets) != 0 {
1427
+ t.Fatalf("ResetForRecovery calls = %v, want none", base.resets)
1428
+ }
1429
+ for _, event := range log.all() {
1430
+ if strings.HasPrefix(event, "ensureMailboxes:") || strings.HasPrefix(event, "createMailbox:") {
1431
+ t.Fatalf("mailbox mutation after owner mismatch: %v", log.all())
1432
+ }
1433
+ }
1434
+ if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "basicFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
1435
+ t.Fatalf("durable runs after owner mismatch = %v, %v; want none", runs, err)
1436
+ }
1437
+ }
1438
+
1439
+ func TestServeRecoverSkipsLegacyClaimWithoutProvenance(t *testing.T) {
1440
+ log := newEventLog()
1441
+ base := newFakeTaskSystem(log)
1442
+ sys := &legacyRecoveryTaskSystem{fakeTaskSystem: base}
1443
+ sys.parentsToRecover = []task.Ticket{{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}}
1444
+ fr := newFakeRunner(log)
1445
+ engine := newEngine(t, goworkflows.Dependencies{
1446
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
1447
+ })
1448
+ if err := recoverTickets(context.Background(), engine, sys, fr, linearWorkflow(false)); err != nil {
1449
+ t.Fatalf("legacy-claim recovery failed: %v", err)
1450
+ }
1451
+ if len(fr.closedRun) != 0 || base.hasCommentCount() != 0 || len(base.resets) != 0 {
1452
+ t.Fatalf("legacy claim caused recovery side effects: closed=%v comments=%d resets=%v", fr.closedRun, base.hasCommentCount(), base.resets)
1453
+ }
1454
+ if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "basicFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
1455
+ t.Fatalf("legacy claim durable runs = %v, %v; want none", runs, err)
1456
+ }
1457
+ }
1458
+
1379
1459
  func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
1380
1460
  log := newEventLog()
1381
1461
  sys := newFakeTaskSystem(log)
@@ -85,7 +85,11 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
85
85
  if err != nil {
86
86
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
87
87
  }
88
- specs[i].Description = appendText(custom, specs[i].Description)
88
+ if data.Report != "" && strings.Contains(custom, data.Report) {
89
+ specs[i].Description = custom
90
+ } else {
91
+ specs[i].Description = appendText(custom, specs[i].Description)
92
+ }
89
93
  }
90
94
  return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
91
95
  }
@@ -486,26 +490,9 @@ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflo
486
490
  var b strings.Builder
487
491
  b.WriteString(`Required report format:
488
492
 
489
- STATUS: success | failure
490
- NEXT STEP: <one valid node name>
491
-
492
- SUMMARY:
493
- COMPLETED:
494
- COMMITS:
495
- NOT COMPLETED:
496
- ISSUES DISCOVERED:
497
- VERIFICATION:
498
- NOTES:
499
-
500
- FEEDBACK:
501
- REASON FOR NEXT STEP:
502
- REQUIRED ACTIONS:
503
- RELEVANT CONTEXT:
504
- EXPECTED RESULT:
493
+ ` + workflow.ReportFormat + `
505
494
 
506
- Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
507
-
508
- 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.`)
495
+ 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, FEEDBACK must be None.`)
509
496
  writeRoutes := func(label string, routes []workflow.Route) {
510
497
  if len(routes) == 0 {
511
498
  return
@@ -533,7 +520,7 @@ Node names identify workflow stages; they are not task-system statuses. STATUS d
533
520
  Agent: n.Agent, NodeDescription: n.Description,
534
521
  NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
535
522
  SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
536
- Mailbox: ticketKey + ":" + name,
523
+ Mailbox: ticketKey + ":" + name, Report: workflow.ReportFormat,
537
524
  },
538
525
  }
539
526
  }
@@ -567,7 +554,11 @@ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]t
567
554
  if err != nil {
568
555
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
569
556
  }
570
- specs[i].Description = appendText(custom, specs[i].Description)
557
+ if data.Report != "" && strings.Contains(custom, data.Report) {
558
+ specs[i].Description = custom
559
+ } else {
560
+ specs[i].Description = appendText(custom, specs[i].Description)
561
+ }
571
562
  }
572
563
  return specs, nil
573
564
  }