relay-flow 0.2.0-alpha → 0.2.2-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 (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -2,12 +2,15 @@ package goworkflows_test
2
2
 
3
3
  import (
4
4
  "context"
5
+ "database/sql"
5
6
  "path/filepath"
6
7
  "strings"
7
8
  "testing"
8
9
  "time"
9
10
 
10
11
  "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
12
+ "github.com/rajpopat27/relay-flow/internal/harness"
13
+ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
11
14
  "github.com/rajpopat27/relay-flow/internal/identity"
12
15
  "github.com/rajpopat27/relay-flow/internal/repo"
13
16
  "github.com/rajpopat27/relay-flow/internal/run"
@@ -39,38 +42,45 @@ func linearWorkflow(cleanup bool) workflow.Workflow {
39
42
  }
40
43
  }
41
44
 
42
- func TestMailboxDescriptionRequiresQuestionForHITL(t *testing.T) {
45
+ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
43
46
  wf := linearWorkflow(false)
44
47
  node := wf.Nodes["coding"]
45
48
  node.Type = workflow.NodeHITL
46
- description := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
49
+ spec := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node)
50
+ description := spec.Description
47
51
  for _, want := range []string{
48
- "Do not make code changes",
49
- "until the human is satisfied with the review",
50
- "OpenCode's Question tool",
51
- "Approve and Reject",
52
- "If approved, output the report verbatim",
53
- "If rejected, return to step 1",
52
+ "Required report format:",
53
+ "Node names identify workflow stages",
54
+ "SUMMARY is written to this current mailbox",
55
+ "requested changes in FEEDBACK",
54
56
  } {
55
57
  if !strings.Contains(description, want) {
56
58
  t.Fatalf("HITL mailbox description missing %q:\n%s", want, description)
57
59
  }
58
60
  }
59
-
60
- node.Type = workflow.NodeAgent
61
- agentDescription := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
62
- if strings.Contains(agentDescription, "Question tool") {
63
- t.Fatalf("agent mailbox description contains HITL Question instruction:\n%s", agentDescription)
61
+ for _, unwanted := range []string{"Jira", "OpenCode", "Question tool", "Approve and Reject", "Discuss the task with the human"} {
62
+ if strings.Contains(description, unwanted) {
63
+ t.Fatalf("generic mailbox description contains %q:\n%s", unwanted, description)
64
+ }
64
65
  }
65
66
 
66
- prompt := goworkflows.BuildLaunchSpecPrompt("PAY-101", "PAY-234")
67
- for _, want := range []string{"parent Jira ticket PAY-101", "mailbox subtask is PAY-234", "description and comments"} {
68
- if !strings.Contains(prompt, want) {
69
- t.Fatalf("compact launch prompt missing %q: %q", want, prompt)
67
+ for _, taskSystem := range []string{"jira", "linear"} {
68
+ prompt, err := opencode.New().RenderPrompt(harness.PromptInitial, harness.PromptData{
69
+ TaskSystem: taskSystem, Ticket: "PAY-101", Mailbox: "PAY-234", NodeType: workflow.NodeHITL,
70
+ }, "")
71
+ if err != nil {
72
+ t.Fatal(err)
73
+ }
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\nBefore submitting your report, present the complete proposed report through OpenCode's built-in Question tool with exactly two options: Approve and Reject. Submit it only after an explicit Approve answer."
75
+ if prompt != want {
76
+ t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
77
+ }
78
+ if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
79
+ t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
80
+ }
81
+ if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
82
+ t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
70
83
  }
71
- }
72
- if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
73
- t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
74
84
  }
75
85
  }
76
86
 
@@ -104,6 +114,50 @@ func newEngine(t *testing.T, deps goworkflows.Dependencies) *goworkflows.Engine
104
114
  return e
105
115
  }
106
116
 
117
+ func TestEnsureRunCreatesMissingWorkflowForExistingProjection(t *testing.T) {
118
+ log := newEventLog()
119
+ sys := newFakeTaskSystem(log)
120
+ path := filepath.Join(t.TempDir(), "state.db")
121
+ deps := goworkflows.Dependencies{
122
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
123
+ }
124
+ engine, err := goworkflows.New(path, deps)
125
+ if err != nil {
126
+ t.Fatal(err)
127
+ }
128
+ wf := linearWorkflow(false)
129
+ rid := identity.NewRunID("payments", wf.Name, "PAY-101")
130
+ db, err := sql.Open("sqlite", path)
131
+ if err != nil {
132
+ t.Fatal(err)
133
+ }
134
+ if _, err := db.Exec(`INSERT INTO relay_runs
135
+ (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
136
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, rid, "payments", wf.Name, "1", "PAY-101", run.StateStarting, time.Now(), time.Now()); err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ db.Close()
140
+ if err := engine.Start(context.Background()); err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ t.Cleanup(func() { _ = engine.Shutdown(context.Background()) })
144
+
145
+ created, err := engine.EnsureRun(context.Background(), run.Start{
146
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
147
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
148
+ })
149
+ if err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ if !created {
153
+ t.Fatal("EnsureRun did not create the missing workflow instance")
154
+ }
155
+ waitFor(t, 10*time.Second, func() bool {
156
+ r, err := engine.GetRun(context.Background(), rid)
157
+ return err == nil && r.CurrentNode == "coding"
158
+ })
159
+ }
160
+
107
161
  func successReport(next string) workflow.Report {
108
162
  none := "None"
109
163
  return workflow.Report{
@@ -131,7 +185,11 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
131
185
  Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
132
186
  })
133
187
 
134
- rid, err := startRun(engine, linearWorkflow(false))
188
+ wf := linearWorkflow(false)
189
+ node := wf.Nodes["coding"]
190
+ node.NudgePrompt = "Continue {{ticket}} at {{node}}. Valid next steps: {{nextSteps}}."
191
+ wf.Nodes["coding"] = node
192
+ rid, err := startRun(engine, wf)
135
193
  if err != nil {
136
194
  t.Fatalf("EnsureRun failed: %v", err)
137
195
  }
@@ -155,6 +213,17 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
155
213
  if runtime.TerminalID == "" || runtime.NodeVisitID != r.CurrentNodeVisitID {
156
214
  t.Fatalf("terminal was not persisted for current visit: %+v", runtime)
157
215
  }
216
+ promptCalls := fh.promptCalls()
217
+ if len(promptCalls) != 1 {
218
+ t.Fatalf("RenderPrompt calls = %+v, want one initial prompt", promptCalls)
219
+ }
220
+ call := promptCalls[0]
221
+ if call.NudgeTemplate != node.NudgePrompt {
222
+ t.Fatalf("nudge passed to harness = %q, want raw template %q", call.NudgeTemplate, node.NudgePrompt)
223
+ }
224
+ if call.Data.Ticket != "PAY-101" || call.Data.Workflow != wf.Name || call.Data.Repo != "payments" || call.Data.Node != "coding" || call.Data.NextSteps == "" {
225
+ t.Fatalf("nudge prompt data = %+v, want current workflow values", call.Data)
226
+ }
158
227
 
159
228
  // Pre-edge gate: before following the start edge the run ensures the
160
229
  // runner environment AND validates every referenced agent, and applies
@@ -233,6 +302,9 @@ func TestRevisitCreatesNewVisit(t *testing.T) {
233
302
  r, _ := engine.GetRun(context.Background(), rid)
234
303
  return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
235
304
  })
305
+ if indexOf(log.all(), "environmentStatus:in-progress") < 0 {
306
+ t.Fatalf("agent node did not set workspace status in-progress; events=%v", log.all())
307
+ }
236
308
 
237
309
  r, _ := engine.GetRun(context.Background(), rid)
238
310
  first := r.CurrentNodeVisitID
@@ -329,6 +401,9 @@ func TestEndCleanupDisabledKeepsRetainedRunner(t *testing.T) {
329
401
  r, _ := engine.GetRun(context.Background(), rid)
330
402
  return r.State == run.StateCompleted
331
403
  })
404
+ if indexOf(log.all(), "environmentStatus:completed") < 0 {
405
+ t.Fatalf("end did not set workspace status completed; events=%v", log.all())
406
+ }
332
407
  if len(fr.cleaned) != 0 || fr.liveTerminals() != 1 {
333
408
  t.Fatalf("cleanup disabled: CleanupRun calls=%v live terminals=%d, want 0 and 1", fr.cleaned, fr.liveTerminals())
334
409
  }
@@ -378,6 +453,10 @@ func TestTransitionOrdering(t *testing.T) {
378
453
  r, _ := engine.GetRun(context.Background(), rid)
379
454
  return r.CurrentNode == "review"
380
455
  })
456
+ events := log.all()
457
+ if statusIdx, terminalIdx := indexOf(events, "environmentStatus:in-review"), indexOf(events, "ensureTerminal:PAY-101:review"); statusIdx < 0 || terminalIdx < 0 || statusIdx >= terminalIdx {
458
+ t.Fatalf("HITL status was not set before terminal start; events=%v", events)
459
+ }
381
460
 
382
461
  // Exact cross-primitive order, observed through the fake-adapter and fake-
383
462
  // runner call logs (the settled observation seam):
@@ -388,7 +467,6 @@ func TestTransitionOrdering(t *testing.T) {
388
467
  // (recovery_test.go): a crash after acceptance, with comment injection
389
468
  // failing, restarts on the same db and resumes the PERSISTED selected route
390
469
  // without re-asking the agent or re-running effects.
391
- events := log.all()
392
470
  idx := map[string]int{}
393
471
  for _, want := range []string{
394
472
  "comment:PAY-101-coding", // summary to current mailbox
@@ -74,6 +74,7 @@ type fakeTaskSystem struct {
74
74
  specs []task.MailboxSpec
75
75
  comments []recordedComment
76
76
  resets []string
77
+ renderText func(task.TextKind, task.TextData) (string, error)
77
78
 
78
79
  // Failure/crash/slow injection — the fake IS the documented injection
79
80
  // seam (allowed seam a). These make the fake adapter fail/stall so the
@@ -122,6 +123,22 @@ func (s *fakeTaskSystem) ValidateConfig(context.Context, config.RawValues, map[s
122
123
  return nil
123
124
  }
124
125
 
126
+ func (s *fakeTaskSystem) RenderText(kind task.TextKind, data task.TextData) (string, error) {
127
+ if s.renderText != nil {
128
+ return s.renderText(kind, data)
129
+ }
130
+ switch kind {
131
+ case task.TextMailboxDescription:
132
+ return "Parent ticket: " + data.Ticket + "\nNode: " + data.Node + "\nType: " + data.NodeType + "\nAgent: " + data.Agent + "\nWork: " + data.NodeDescription + "\nMailbox: " + data.Mailbox, nil
133
+ case task.TextSummaryComment:
134
+ return "SUMMARY\n" + data.SummaryReport, nil
135
+ case task.TextFeedbackComment:
136
+ return "Feedback from " + data.SourceNode + " to " + data.TargetNode + " mailbox " + data.Mailbox + "\n" + data.FeedbackReport, nil
137
+ default:
138
+ return "", fmt.Errorf("unknown task text kind %q", kind)
139
+ }
140
+ }
141
+
125
142
  func (s *fakeTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRef, wf string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
126
143
  s.log.add("ensureMailboxes:" + parent.Key)
127
144
  s.mu.Lock()
@@ -337,21 +354,17 @@ func (f *fakeRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (
337
354
  return e, nil
338
355
  }
339
356
 
340
- func (f *fakeRunner) FindTerminal(_ context.Context, env runner.Environment, title string) (runner.Terminal, bool, error) {
341
- f.mu.Lock()
342
- defer f.mu.Unlock()
343
- f.log.add("findTerminal:" + title)
344
- ft, ok := f.terminals[env.ID+"/"+title]
345
- if !ok || !ft.live {
346
- return runner.Terminal{}, false, nil
347
- }
348
- return ft.term, true, nil
357
+ func (f *fakeRunner) SetEnvironmentStatus(_ context.Context, _ runner.Environment, status string) error {
358
+ f.log.add("environmentStatus:" + status)
359
+ return nil
349
360
  }
350
361
 
351
- func (f *fakeRunner) InspectTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
362
+ func (f *fakeRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
352
363
  f.mu.Lock()
353
364
  defer f.mu.Unlock()
354
- f.log.add("inspectTerminal:" + terminal.ID)
365
+ if terminal.ID != "" {
366
+ f.log.add("findTerminalID:" + terminal.ID)
367
+ }
355
368
  for _, ft := range f.terminals {
356
369
  if ft.term.ID == terminal.ID && ft.live {
357
370
  return ft.term, true, nil
@@ -381,17 +394,13 @@ func (f *fakeRunner) CreateTerminal(ctx context.Context, env runner.Environment,
381
394
  return t, nil
382
395
  }
383
396
 
384
- func (f *fakeRunner) EnsureTerminal(_ context.Context, env runner.Environment, title string, _ runner.Command) (runner.Terminal, error) {
385
- f.mu.Lock()
386
- defer f.mu.Unlock()
387
- key := env.ID + "/" + title
388
- if ft, ok := f.terminals[key]; ok && ft.live {
389
- return ft.term, nil
397
+ func (f *fakeRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
398
+ if terminal, ok, err := f.FindTerminal(ctx, stored); err != nil {
399
+ return runner.Terminal{}, err
400
+ } else if ok {
401
+ return terminal, nil
390
402
  }
391
- t := runner.Terminal{ID: "t-" + key, Title: title}
392
- f.terminals[key] = &fakeTerminal{term: t, live: true, title: title}
393
- f.log.add("ensureTerminal:" + title)
394
- return t, nil
403
+ return f.CreateTerminal(ctx, env, title, command)
395
404
  }
396
405
 
397
406
  func (f *fakeRunner) CloseTerminal(_ context.Context, t runner.Terminal) error {
@@ -463,16 +472,25 @@ func (f *fakeRunner) killTerminals() {
463
472
  type fakeHarness struct {
464
473
  log *eventLog
465
474
 
466
- mu sync.Mutex
467
- validated []string
468
- sessions map[string]harness.Session
469
- reconcileNudge int // nudges sent to idle live HITL sessions (must stay 0)
475
+ mu sync.Mutex
476
+ validated []string
477
+ sessions map[string]harness.Session
478
+ renderedPrompts []renderedPromptCall
479
+ reconcileNudge int // nudges sent to idle live HITL sessions (must stay 0)
480
+ }
481
+
482
+ type renderedPromptCall struct {
483
+ Kind harness.PromptKind
484
+ Data harness.PromptData
485
+ NudgeTemplate string
470
486
  }
471
487
 
472
488
  func newFakeHarness(log *eventLog) *fakeHarness {
473
489
  return &fakeHarness{log: log, sessions: map[string]harness.Session{}}
474
490
  }
475
491
 
492
+ func (f *fakeHarness) SetupRepo(context.Context, string) error { return nil }
493
+
476
494
  func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
477
495
  f.mu.Lock()
478
496
  f.validated = append(f.validated, agent)
@@ -489,6 +507,26 @@ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.S
489
507
  return s, ok, nil
490
508
  }
491
509
 
510
+ func (f *fakeHarness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
511
+ f.mu.Lock()
512
+ f.renderedPrompts = append(f.renderedPrompts, renderedPromptCall{Kind: kind, Data: data, NudgeTemplate: nudge})
513
+ f.mu.Unlock()
514
+ prompt := string(kind) + ":" + data.TaskSystem + ":" + data.Mailbox
515
+ if data.NodeType == workflow.NodeHITL {
516
+ prompt += ":hitl"
517
+ }
518
+ if nudge != "" {
519
+ prompt += ":" + nudge
520
+ }
521
+ return prompt, nil
522
+ }
523
+
524
+ func (f *fakeHarness) promptCalls() []renderedPromptCall {
525
+ f.mu.Lock()
526
+ defer f.mu.Unlock()
527
+ return append([]renderedPromptCall(nil), f.renderedPrompts...)
528
+ }
529
+
492
530
  func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
493
531
  f.log.add("buildCommand:" + spec.Node + ":" + string(spec.NodeVisitID) + ":resume=" + spec.ResumeID)
494
532
  return runner.Command{Executable: "opencode"}, nil
@@ -188,21 +188,9 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
188
188
 
189
189
  title := start.Ticket.Key + ":" + current
190
190
 
191
- // Build the finished launch metadata from the workflow snapshot: the
192
- // prompt carries the node description, optional custom instructions,
193
- // complete report contract, and valid next steps. Custom instructions
194
- // are also retained separately for live-terminal revisits.
191
+ // Build task-system-neutral prompt data from the workflow snapshot. The
192
+ // selected harness owns rendering initial, feedback, and HITL text.
195
193
  nextSteps := append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...)
196
- nudge, err := wf.RenderNudge(current, workflow.NudgeTemplateData{
197
- Ticket: start.Ticket.Key,
198
- Workflow: wf.Name,
199
- Repo: start.Repo,
200
- Node: current,
201
- NextSteps: nextStepsText(nextSteps),
202
- })
203
- if err != nil {
204
- return err
205
- }
206
194
  spec := harness.LaunchSpec{
207
195
  RunID: start.ID,
208
196
  NodeVisitID: visitID,
@@ -214,9 +202,20 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
214
202
  NodeType: node.Type,
215
203
  Agent: node.Agent,
216
204
  Title: title,
217
- Prompt: BuildLaunchSpecPrompt(start.Ticket.Key, mb.Key),
218
- NudgePrompt: nudge,
219
- NextSteps: nextSteps,
205
+ NudgePrompt: node.NudgePrompt,
206
+ PromptData: harness.PromptData{
207
+ TaskSystem: a.TaskSystem,
208
+ Ticket: start.Ticket.Key,
209
+ Workflow: wf.Name,
210
+ Repo: start.Repo,
211
+ Node: current,
212
+ NodeType: node.Type,
213
+ Agent: node.Agent,
214
+ NodeDescription: node.Description,
215
+ NextSteps: nextStepsText(nextSteps),
216
+ Mailbox: mb.Key,
217
+ },
218
+ NextSteps: nextSteps,
220
219
  }
221
220
  if runtime.SessionID != "" {
222
221
  spec.ResumeID = runtime.SessionID
@@ -304,10 +303,14 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
304
303
  // complete current -> process next node.
305
304
  if _, err := retryLoop(ctx, start.ID, a, work, current,
306
305
  func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
306
+ summaryReport := renderSummaryReport(report)
307
307
  return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
308
- RunID: start.ID,
309
- Item: task.Target{Parent: work.Parent, Mailbox: &mb},
310
- Body: renderSummary(report),
308
+ RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &mb},
309
+ TextKind: task.TextSummaryComment,
310
+ TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key,
311
+ Workflow: wf.Name, Repo: start.Repo, Node: current, NodeType: string(node.Type),
312
+ Agent: node.Agent, NodeDescription: node.Description, Mailbox: mb.Key,
313
+ SourceNode: current, TargetNode: current, SummaryReport: summaryReport},
311
314
  Marker: string(visitID) + ":summary",
312
315
  })
313
316
  }); err != nil {
@@ -319,10 +322,15 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
319
322
  nextMb := mailboxes[next]
320
323
  if _, err := retryLoop(ctx, start.ID, a, work, current,
321
324
  func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
325
+ feedbackReport := renderFeedbackReport(report)
322
326
  return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
323
- RunID: start.ID,
324
- Item: task.Target{Parent: work.Parent, Mailbox: &nextMb},
325
- Body: renderFeedback(current, report),
327
+ RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &nextMb},
328
+ TextKind: task.TextFeedbackComment,
329
+ TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key,
330
+ Workflow: wf.Name, Repo: start.Repo, Node: next, NodeType: string(wf.Nodes[next].Type),
331
+ Agent: wf.Nodes[next].Agent, NodeDescription: wf.Nodes[next].Description, Mailbox: nextMb.Key,
332
+ SourceNode: current, TargetNode: next, SummaryReport: renderSummaryReport(report),
333
+ FeedbackReport: feedbackReport},
326
334
  Marker: string(visitID) + ":feedback",
327
335
  })
328
336
  }); err != nil {
@@ -360,6 +368,13 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
360
368
  }); err != nil {
361
369
  return err
362
370
  }
371
+ if _, err := retryLoop(ctx, start.ID, a, work, "end",
372
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
373
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
374
+ a.SetEnvironmentStatus, work, start.RepoPath, runner.WorkspaceStatusCompleted)
375
+ }); err != nil {
376
+ return err
377
+ }
363
378
  finalPolicy := work.Runtime
364
379
  if wf.CleanupRunnerOnEnd {
365
380
  finalPolicy.KeepTerminalsAlive = false
@@ -401,7 +416,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
401
416
  // run-level activities like EnsureMailboxes / start/end ApplyTaskConfig).
402
417
  // The log is wrapped in a replay-safe SideEffect so replays never re-emit
403
418
  // it, and the error message is sanitized so info logs never embed argv
404
- // payloads (acli/orca --body/--command/JQL/prompt strings).
419
+ // payloads (Jira/Orca request bodies, commands, JQL, and prompt strings).
405
420
  func retryLoop[T any](ctx goworkflow.Context, id run.ID, a *Activities, work run.Work, node string, schedule func(goworkflow.Context) goworkflow.Future[T]) (T, error) {
406
421
  var zero T
407
422
  attempt := 0
@@ -475,7 +490,7 @@ func logRetry(ctx goworkflow.Context, work run.Work, node string, f retry.Failur
475
490
  }
476
491
 
477
492
  // sanitizeRetryMessage strips each "[...]: " argv-listing span that adapter
478
- // wrappers embed (acli [args...]:, orca [args...]:) so the info-level retry
493
+ // wrappers may embed argv/request context, so the info-level retry
479
494
  // record never leaks --body/--command/JQL/prompt payloads. Keeps the
480
495
  // surrounding wrap context and trailing exit status / stderr fragment that
481
496
  // carry the failure reason. The original error returned to callers is
@@ -594,12 +609,12 @@ func nextStepsText(routes []workflow.Route) string {
594
609
  return b.String()
595
610
  }
596
611
 
597
- func renderSummary(r workflow.Report) string {
598
- return fmt.Sprintf("SUMMARY\nCOMPLETED:\n%s\n\nCOMMITS:\n%s\n\nNOT COMPLETED:\n%s\n\nISSUES DISCOVERED:\n%s\n\nVERIFICATION:\n%s\n\nNOTES:\n%s\n",
612
+ func renderSummaryReport(r workflow.Report) string {
613
+ 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",
599
614
  r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
600
615
  }
601
616
 
602
- func renderFeedback(source string, r workflow.Report) string {
603
- return fmt.Sprintf("Feedback from %s\nCOMMITS:\n%s\n\nREASON FOR NEXT STEP:\n%s\n\nREQUIRED ACTIONS:\n%s\n\nRELEVANT CONTEXT:\n%s\n\nEXPECTED RESULT:\n%s\n",
604
- source, r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
617
+ func renderFeedbackReport(r workflow.Report) string {
618
+ 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",
619
+ r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
605
620
  }
@@ -266,6 +266,91 @@ func TestSummaryCurrentFeedbackSelectedNextOnly(t *testing.T) {
266
266
  }
267
267
  }
268
268
 
269
+ func TestTaskSystemTemplatesRenderMailboxAndSplitOneReport(t *testing.T) {
270
+ log := newEventLog()
271
+ sys := newFakeTaskSystem(log)
272
+ seen := map[task.TextKind][]task.TextData{}
273
+ sys.renderText = func(kind task.TextKind, data task.TextData) (string, error) {
274
+ seen[kind] = append(seen[kind], data)
275
+ switch kind {
276
+ case task.TextMailboxDescription:
277
+ return "custom mailbox " + data.Node + " work=" + data.NodeDescription, nil
278
+ case task.TextSummaryComment:
279
+ return "custom summary node=" + data.Node + " mailbox=" + data.Mailbox + "\n" + data.SummaryReport, nil
280
+ case task.TextFeedbackComment:
281
+ return "custom feedback source=" + data.SourceNode + " target=" + data.TargetNode + " mailbox=" + data.Mailbox + "\n" + data.FeedbackReport, nil
282
+ default:
283
+ return "", nil
284
+ }
285
+ }
286
+ engine := newEngine(t, goworkflows.Dependencies{
287
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log), TaskSystem: "custom-task",
288
+ })
289
+ rid, _ := startRun(engine, threeNodeWorkflow())
290
+ waitFor(t, 10*time.Second, func() bool {
291
+ r, _ := engine.GetRun(context.Background(), rid)
292
+ return r.CurrentNode == "exploration"
293
+ })
294
+ if got := sys.specs[1].Description; !strings.Contains(got, "custom mailbox exploration work=explore the code") || !strings.Contains(got, "Required report format:") {
295
+ t.Fatalf("rendered mailbox description = %q", got)
296
+ }
297
+ var explorationData task.TextData
298
+ for _, data := range seen[task.TextMailboxDescription] {
299
+ if data.Node == "exploration" {
300
+ explorationData = data
301
+ }
302
+ }
303
+ for name, got := range map[string]string{
304
+ "runID": explorationData.RunID,
305
+ "ticket": explorationData.Ticket, "workflow": explorationData.Workflow,
306
+ "repo": explorationData.Repo, "node": explorationData.Node,
307
+ "nodeType": explorationData.NodeType, "agent": explorationData.Agent,
308
+ "nodeDescription": explorationData.NodeDescription, "mailbox": explorationData.Mailbox,
309
+ } {
310
+ if got == "" {
311
+ t.Fatalf("mailbox template value %s was empty: %+v", name, explorationData)
312
+ }
313
+ }
314
+ for name, got := range map[string]string{"nextSteps": explorationData.NextSteps, "successRoutes": explorationData.SuccessRoutes, "failureRoutes": explorationData.FailureRoutes} {
315
+ if !strings.Contains(got, "coding") && name != "failureRoutes" {
316
+ t.Fatalf("mailbox template %s = %q", name, got)
317
+ }
318
+ if name == "failureRoutes" && !strings.Contains(got, "exploration") {
319
+ t.Fatalf("mailbox template failureRoutes = %q", got)
320
+ }
321
+ }
322
+ report := successReport("coding")
323
+ report.Feedback = workflow.Feedback{ReasonForNextStep: "reviewed", RequiredActions: "implement", RelevantContext: "ctx", ExpectedResult: "done"}
324
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "exploration", report)); err != nil {
325
+ t.Fatal(err)
326
+ }
327
+ waitFor(t, 10*time.Second, func() bool {
328
+ r, _ := engine.GetRun(context.Background(), rid)
329
+ return r.CurrentNode == "coding"
330
+ })
331
+ summary := sys.commentBodies("PAY-101-exploration")
332
+ feedback := sys.commentBodies("PAY-101-coding")
333
+ if len(summary) != 1 || !strings.Contains(summary[0].Body, "custom summary node=exploration mailbox=PAY-101-exploration") || !strings.Contains(summary[0].Body, "COMPLETED:") {
334
+ t.Fatalf("summary comments = %+v", summary)
335
+ }
336
+ if len(feedback) != 1 || !strings.Contains(feedback[0].Body, "source=exploration target=coding mailbox=PAY-101-coding") || !strings.Contains(feedback[0].Body, "REQUIRED ACTIONS:") {
337
+ t.Fatalf("feedback comments = %+v", feedback)
338
+ }
339
+ if len(seen[task.TextSummaryComment]) != 1 || seen[task.TextSummaryComment][0].SummaryReport == "" {
340
+ t.Fatalf("summary template data = %+v", seen[task.TextSummaryComment])
341
+ }
342
+ if len(seen[task.TextFeedbackComment]) != 1 {
343
+ t.Fatalf("feedback template data = %+v", seen[task.TextFeedbackComment])
344
+ }
345
+ feedbackData := seen[task.TextFeedbackComment][0]
346
+ if feedbackData.SourceNode != "exploration" || feedbackData.TargetNode != "coding" || feedbackData.Mailbox != "PAY-101-coding" || feedbackData.FeedbackReport == "" {
347
+ t.Fatalf("feedback template data = %+v", feedbackData)
348
+ }
349
+ if len(sys.commentBodies("PAY-101-review")) != 0 {
350
+ t.Fatal("one report sent feedback to an unselected mailbox")
351
+ }
352
+ }
353
+
269
354
  // 3.28: end/mailbox behavior, manual status not routing, HITL lifecycle.
270
355
 
271
356
  func TestManualMailboxStatusDoesNotRouteGraph(t *testing.T) {
@@ -84,8 +84,8 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
84
84
  if err != nil || !staleAck.Duplicate {
85
85
  t.Fatalf("stale ack=%+v err=%v", staleAck, err)
86
86
  }
87
- if log.count("findTerminal:") != 0 {
88
- t.Fatalf("normal path used title discovery: %v", log.all())
87
+ if log.count("findTerminalID:") == 0 {
88
+ t.Fatalf("normal path did not check persisted terminal IDs: %v", log.all())
89
89
  }
90
90
  if log.count("findSession:") != 0 {
91
91
  t.Fatalf("normal path used session discovery: %v", log.all())
@@ -93,7 +93,7 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
93
93
 
94
94
  oldTerminal := secondRT.TerminalID
95
95
  fr.killTerminals()
96
- findTerminalBefore := log.count("findTerminal:")
96
+ findTerminalBefore := log.count("findTerminalID:")
97
97
  findSessionBefore := log.count("findSession:")
98
98
  e2 := restartEngine(t, db, deps, e1)
99
99
  waitFor(t, 10*time.Second, func() bool {
@@ -104,13 +104,19 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
104
104
  t.Fatal(err)
105
105
  }
106
106
  resumeEvent := "buildCommand:implement:" + string(second.CurrentNodeVisitID) + ":resume=session-implement"
107
- waitFor(t, 10*time.Second, func() bool { return log.count(resumeEvent) == 1 })
107
+ waitFor(t, 10*time.Second, func() bool {
108
+ runtime, _ := e2.GetNodeRuntime(context.Background(), rid, "implement")
109
+ return runtime.TerminalID != "" && runtime.TerminalID != oldTerminal
110
+ })
108
111
  after, _ := e2.GetNodeRuntime(context.Background(), rid, "implement")
109
112
  if after.TerminalID == oldTerminal || after.SessionID != "session-implement" || after.NodeVisitID != second.CurrentNodeVisitID {
110
113
  t.Fatalf("restart did not resume/replace direct runtime: old=%+v after=%+v", secondRT, after)
111
114
  }
112
- if log.count("findTerminal:") != findTerminalBefore || log.count("findSession:") != findSessionBefore {
113
- t.Fatalf("restart used discovery: %v", log.all())
115
+ if log.count("findTerminalID:") == findTerminalBefore || log.count("findSession:") != findSessionBefore {
116
+ t.Fatalf("restart did not use only the persisted terminal/session IDs: %v", log.all())
117
+ }
118
+ if log.count(resumeEvent) < 1 {
119
+ t.Fatalf("restart did not pass persisted session ID to harness: %v", log.all())
114
120
  }
115
121
  }
116
122